onLoad(analysisToIons(a))}
+ />
)}
{t("recipe.water.mab_attribution")}
diff --git a/apps/desktop/src/data/i18n.ts b/apps/desktop/src/data/i18n.ts
index 4e24392..175df19 100644
--- a/apps/desktop/src/data/i18n.ts
+++ b/apps/desktop/src/data/i18n.ts
@@ -736,10 +736,6 @@ const STRINGS: Record = {
"recipe.water.mab_insee": { en: "INSEE code", fr: "Code INSEE" },
"recipe.water.mab_load": { en: "Load", fr: "Charger" },
"recipe.water.mab_loading": { en: "Loading…", fr: "Chargement…" },
- "recipe.water.mab_desktop_only": {
- en: "Live commune lookup is available in the desktop app only. In the browser, enter the ions manually.",
- fr: "La recherche par commune n'est disponible que dans l'application de bureau. Dans le navigateur, saisissez les ions manuellement.",
- },
"recipe.water.mab_networks": { en: "Networks for {insee} — pick one", fr: "Réseaux pour {insee} — choisissez-en un" },
"recipe.water.mab_attribution": {
en: "Water data © moneaudebrassage.fr",
@@ -1166,10 +1162,6 @@ const STRINGS: Record = {
en: "Enter a valid 5-digit INSEE commune code (e.g. 73008).",
fr: "Saisissez un code INSEE de commune à 5 chiffres (ex. 73008).",
},
- "error.water.mab.web_unsupported": {
- en: "Commune lookup needs the desktop app — enter the ions manually here.",
- fr: "La recherche par commune nécessite l'application de bureau — saisissez les ions manuellement ici.",
- },
"error.water.mab.not_found": {
en: "No published water analysis found for that commune.",
fr: "Aucune analyse d'eau publiée pour cette commune.",
diff --git a/apps/desktop/src/data/moneaudebrassage.ts b/apps/desktop/src/data/moneaudebrassage.ts
index 16f1e84..dae781a 100644
--- a/apps/desktop/src/data/moneaudebrassage.ts
+++ b/apps/desktop/src/data/moneaudebrassage.ts
@@ -7,13 +7,9 @@
* to pre-fill the water-chemistry source ions so FR brewers don't have
* to copy six numbers by hand.
*
- * The request is cross-origin and the API gates on the `Origin` header
- * — neither works from a browser `fetch` (CORS rejects it, and `Origin`
- * is a forbidden header the browser won't let JS set). So the fetch
- * goes through the Tauri HTTP plugin (a native request, no CORS, the
- * `Origin` header passes through). In the web build the feature is
- * unavailable and the caller falls back to manual entry / the last
- * cached pull.
+ * The API answers CORS requests from any origin, so the web build uses
+ * the browser `fetch`. The desktop app goes through the Tauri HTTP
+ * plugin, scoped to the API host in `capabilities/default.json`.
*
* Data © moneaudebrassage.fr — attribute the source in the UI.
*/
@@ -22,7 +18,6 @@ import { isTauri } from "./runtime.ts";
import { WerbError } from "./errors.ts";
const API_BASE = "https://apidb.moneaudebrassage.fr/lastanalyses";
-const ORIGIN = "https://www.moneaudebrassage.fr";
/** One distribution network's latest ion analysis (ppm). */
export interface WaterAnalysis {
@@ -95,28 +90,23 @@ function toAnalysis(q: RawQuartier): WaterAnalysis {
* Fetch and parse the latest analyses for an INSEE commune code.
*
* Throws a {@link WerbError} the UI can translate:
- * - `water.mab.web_unsupported` — not running under Tauri.
- * - `water.mab.bad_code` — code isn't a valid INSEE code.
- * - `water.mab.not_found` — commune has no published analysis.
- * - `water.mab.network` — request failed (offline, 5xx, …).
+ * - `water.mab.bad_code` — code isn't a valid INSEE code.
+ * - `water.mab.not_found` — commune has no published analysis.
+ * - `water.mab.network` — request failed (offline, 5xx, …).
*/
export async function fetchCommuneAnalyses(rawCode: string): Promise {
const code = rawCode.trim().toUpperCase();
if (!isValidInsee(code)) {
throw new WerbError("water.mab.bad_code");
}
- if (!isTauri()) {
- throw new WerbError("water.mab.web_unsupported");
- }
-
// Dynamic import keeps the Tauri HTTP plugin out of the web bundle.
- const { fetch: tauriFetch } = await import("@tauri-apps/plugin-http");
+ const request = isTauri() ? (await import("@tauri-apps/plugin-http")).fetch : fetch;
let res: Response;
try {
- res = await tauriFetch(`${API_BASE}/${encodeURIComponent(code)}`, {
+ res = await request(`${API_BASE}/${encodeURIComponent(code)}`, {
method: "GET",
- headers: { Accept: "application/json", Origin: ORIGIN },
+ headers: { Accept: "application/json" },
});
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
diff --git a/apps/desktop/test/e2e/smoke.spec.ts b/apps/desktop/test/e2e/smoke.spec.ts
index 561b0cb..57632cc 100644
--- a/apps/desktop/test/e2e/smoke.spec.ts
+++ b/apps/desktop/test/e2e/smoke.spec.ts
@@ -1,4 +1,4 @@
-import { expect, test } from "@playwright/test";
+import { expect, test, type Page } from "@playwright/test";
import { App } from "./pages.js";
/**
@@ -202,6 +202,59 @@ test.describe("Water chemistry — source = target reports no delta", () => {
await page.locator('[data-testid="home-water"]').waitFor();
});
+ test.describe("My water from moneaudebrassage.fr", () => {
+ // page.route doesn't see requests that go through a service worker.
+ test.use({ serviceWorkers: "block" });
+
+ const network = (name: string, ca: number) => ({
+ nomreseau: name,
+ ca,
+ mg: 6,
+ na: 6,
+ cl: 11,
+ so4: 12,
+ hco3: 182,
+ ph: 7.8,
+ date_ca: "2026-09-22",
+ });
+
+ const serveCommune = async (page: Page, quartiers: unknown[]) => {
+ await page.route("https://apidb.moneaudebrassage.fr/lastanalyses/*", (route) =>
+ route.fulfill({
+ contentType: "application/json",
+ headers: { "Access-Control-Allow-Origin": "*" },
+ body: JSON.stringify({ departement: "73", inseecommune: "73008", quartiers }),
+ }),
+ );
+ };
+
+ const lookUp = async (page: Page, code: string) => {
+ await page.getByRole("button", { name: /moneaudebrassage\.fr/i }).click();
+ const panel = page.getByTestId("water-mab-panel");
+ await panel.getByRole("textbox").fill(code);
+ await panel.getByRole("button", { name: /^(Load|Charger)$/ }).click();
+ return panel;
+ };
+
+ test("a commune with one network fills My water in the browser", async ({ page }) => {
+ await serveCommune(page, [network("AIX LES BAINS PORTS", 53)]);
+ const app = new App(page);
+ await app.go("Settings");
+ await lookUp(page, "73008");
+ await expect(page.getByTestId("home-water-ca_ppm")).toHaveValue("53");
+ await expect(page.getByTestId("home-water-hco3_ppm")).toHaveValue("182");
+ });
+
+ test("a commune with several networks lets the brewer pick one", async ({ page }) => {
+ await serveCommune(page, [network("NORTH", 40), network("SOUTH", 90)]);
+ const app = new App(page);
+ await app.go("Settings");
+ const panel = await lookUp(page, "73008");
+ await panel.getByRole("button", { name: /SOUTH/ }).click();
+ await expect(page.getByTestId("home-water-ca_ppm")).toHaveValue("90");
+ });
+ });
+
test("real mismatch still flags deltas (Burton vs Pilsner)", async ({ page }) => {
// Guard against the city-targets fix over-collapsing comparisons.
const app = new App(page);
diff --git a/apps/desktop/test/moneaudebrassage.test.ts b/apps/desktop/test/moneaudebrassage.test.ts
index 029d04d..344bdce 100644
--- a/apps/desktop/test/moneaudebrassage.test.ts
+++ b/apps/desktop/test/moneaudebrassage.test.ts
@@ -2,10 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fetchCommuneAnalyses, isValidInsee } from "../src/data/moneaudebrassage.ts";
import { isWerbError } from "../src/data/errors.ts";
-// The module fetches through the Tauri HTTP plugin (dynamic import).
-// Mock the plugin so we can drive responses without a real network /
-// Tauri runtime.
+// Under Tauri the module fetches through the HTTP plugin (dynamic import),
+// elsewhere through the global fetch. Both are mocked.
const tauriFetch = vi.fn();
+const browserFetch = vi.fn();
vi.mock("@tauri-apps/plugin-http", () => ({ fetch: (...args: unknown[]) => tauriFetch(...args) }));
const TAURI_KEY = "__TAURI_INTERNALS__";
@@ -70,12 +70,15 @@ describe("isValidInsee", () => {
describe("fetchCommuneAnalyses", () => {
beforeEach(() => {
tauriFetch.mockReset();
+ browserFetch.mockReset();
+ vi.stubGlobal("fetch", browserFetch);
(globalThis as Record).window = globalThis as unknown as Window;
(window as unknown as Record)[TAURI_KEY] = {};
});
afterEach(() => {
delete (window as unknown as Record)[TAURI_KEY];
+ vi.unstubAllGlobals();
});
it("rejects a bad code before touching the network", async () => {
@@ -83,11 +86,12 @@ describe("fetchCommuneAnalyses", () => {
expect(tauriFetch).not.toHaveBeenCalled();
});
- it("refuses to run outside Tauri", async () => {
+ it("uses the browser fetch outside Tauri", async () => {
delete (window as unknown as Record)[TAURI_KEY];
- await expect(fetchCommuneAnalyses("73008")).rejects.toMatchObject({
- code: "water.mab.web_unsupported",
- });
+ browserFetch.mockResolvedValue(jsonResponse(SAMPLE));
+ const res = await fetchCommuneAnalyses("73008");
+ expect(res.networks).toHaveLength(1);
+ expect(browserFetch).toHaveBeenCalledOnce();
expect(tauriFetch).not.toHaveBeenCalled();
});
@@ -111,14 +115,14 @@ describe("fetchCommuneAnalyses", () => {
});
});
- it("sends the Origin header the API gates on", async () => {
+ it("asks the API for the commune as JSON", async () => {
tauriFetch.mockResolvedValue(jsonResponse(SAMPLE));
await fetchCommuneAnalyses("73008");
- const [url, opts] = tauriFetch.mock.calls[0];
- expect(url).toContain("/lastanalyses/73008");
- expect((opts as { headers: Record }).headers.Origin).toBe(
- "https://www.moneaudebrassage.fr",
+ expect(tauriFetch).toHaveBeenCalledWith(
+ "https://apidb.moneaudebrassage.fr/lastanalyses/73008",
+ { method: "GET", headers: { Accept: "application/json" } },
);
+ expect(browserFetch).not.toHaveBeenCalled();
});
it("maps a 404 to not_found", async () => {