From 713db2d4fca79596aee39215f52857ca422a26c9 Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Sat, 13 Jun 2026 12:56:19 -0400 Subject: [PATCH] Prove Odoo website bootstrap readback --- docker/scripts/odoo_website_bootstrap.py | 120 ++++++++++++++++-- tests/test_odoo_website_bootstrap.py | 154 ++++++++++++++++++++++- 2 files changed, 259 insertions(+), 15 deletions(-) diff --git a/docker/scripts/odoo_website_bootstrap.py b/docker/scripts/odoo_website_bootstrap.py index 8784710..e0c0e15 100644 --- a/docker/scripts/odoo_website_bootstrap.py +++ b/docker/scripts/odoo_website_bootstrap.py @@ -95,6 +95,17 @@ def _field_value(record: Any, field_name: str) -> object: return getattr(record, field_name, None) +def _record_id(record: Any) -> object: + return getattr(record, "id", record) if record else None + + +def _binary_field_value(record: Any, field_name: str) -> str: + value = _field_value(record, field_name) + if isinstance(value, bytes): + return value.decode("ascii") + return "" if value is None else str(value) + + def _assert_field_value(record: Any, field_name: str, expected_value: object, *, label: str) -> None: if field_name not in record._fields: raise RuntimeError(f"Website bootstrap cannot verify {label}; missing field: {field_name}.") @@ -103,6 +114,25 @@ def _assert_field_value(record: Any, field_name: str, expected_value: object, *, raise RuntimeError(f"Website bootstrap failed to persist {label}: expected {expected_value!r}, got {actual_value!r}.") +def _assert_binary_field_value(record: Any, field_name: str, expected_value: str, *, label: str) -> None: + if field_name not in record._fields: + raise RuntimeError(f"Website bootstrap cannot verify {label}; missing field: {field_name}.") + actual_value = _binary_field_value(record, field_name) + if actual_value != expected_value: + raise RuntimeError( + f"Website bootstrap failed to persist {label}: expected {len(expected_value)} encoded bytes, got {len(actual_value)}." + ) + + +def _assert_field_record_id(record: Any, field_name: str, expected_record: Any, *, label: str) -> None: + if field_name not in record._fields: + raise RuntimeError(f"Website bootstrap cannot verify {label}; missing field: {field_name}.") + expected_id = _record_id(expected_record) + actual_id = _record_id(_field_value(record, field_name)) + if actual_id != expected_id: + raise RuntimeError(f"Website bootstrap failed to persist {label}: expected record {expected_id!r}, got {actual_id!r}.") + + def _config_parameter_value(env: Any, key: str) -> str: parameter_model = env["ir.config_parameter"].sudo() get_param = getattr(parameter_model, "get_param", None) @@ -127,6 +157,41 @@ def _canonical_host(canonical_url: str) -> str: return urlparse(canonical_url).netloc or canonical_url +def _marker_bool(value: bool) -> str: + return "true" if value else "false" + + +def _print_bootstrap_readback( + *, + website: Any, + canonical_url: str, + homepage_url: str, + homepage_page: Any | None, + primary_page_xmlid: str, + primary_page_xmlid_found: bool, + logo_expected: bool, +) -> None: + canonical_host = _canonical_host(canonical_url) + website_domain = str(_field_value(website, "domain") or "") if "domain" in website._fields else "" + actual_homepage_url = str(_field_value(website, "homepage_url") or "") if "homepage_url" in website._fields else "" + actual_homepage = _field_value(website, "homepage_id") if "homepage_id" in website._fields else None + actual_homepage_id = _record_id(actual_homepage) + homepage_page_id = _record_id(homepage_page) + logo_present = bool(_field_value(website, "logo")) if "logo" in website._fields else False + + print(f"website_bootstrap_website_id={getattr(website, 'id', '')}") + print(f"website_bootstrap_domain_set={_marker_bool(bool(website_domain))}") + print(f"website_bootstrap_domain_matches_canonical={_marker_bool(bool(canonical_host) and website_domain == canonical_host)}") + print(f"website_bootstrap_homepage_url_set={_marker_bool(bool(actual_homepage_url))}") + print(f"website_bootstrap_homepage_url_matches={_marker_bool(bool(homepage_url) and actual_homepage_url == homepage_url)}") + print(f"website_bootstrap_homepage_page_found={_marker_bool(bool(homepage_page_id))}") + print(f"website_bootstrap_primary_page_xmlid_found={_marker_bool(bool(primary_page_xmlid) and primary_page_xmlid_found)}") + print( + f"website_bootstrap_homepage_matches_page={_marker_bool(bool(homepage_page_id) and actual_homepage_id == homepage_page_id)}" + ) + print(f"website_bootstrap_logo_present={_marker_bool(not logo_expected or logo_present)}") + + def _select_website(website_model: Any, *, canonical_url: str) -> Any: canonical_host = _canonical_host(canonical_url) if canonical_host and "domain" in website_model._fields: @@ -180,14 +245,15 @@ def _resolve_bootstrap_logo_path(raw_logo_path: object) -> Path | None: raise RuntimeError(f"Website bootstrap logo file not found: {formatted_candidates}") -def _find_website_page(env: Any, website: Any, *, xmlid: str, url: str) -> Any | None: - page = None +def _find_website_page_by_xmlid(env: Any, *, xmlid: str) -> Any | None: if xmlid: candidate = env.ref(xmlid, raise_if_not_found=False) if candidate and candidate._name == "website.page": - page = candidate.sudo() - if page: - return page + return candidate.sudo() + return None + + +def _find_website_page_by_url(env: Any, website: Any, *, url: str) -> Any | None: if not url: return None page_domain: list[Any] = [("url", "=", url)] @@ -196,12 +262,21 @@ def _find_website_page(env: Any, website: Any, *, xmlid: str, url: str) -> Any | return env["website.page"].sudo().search(page_domain, order="website_id desc,id", limit=1) +def _find_website_page(env: Any, website: Any, *, xmlid: str, url: str) -> tuple[Any | None, bool]: + page = _find_website_page_by_xmlid(env, xmlid=xmlid) + if page: + return page, True + if xmlid: + return None, False + return _find_website_page_by_url(env, website, url=url), False + + def _verify_route(env: Any, website: Any, route_payload: dict[str, object], *, fallback_module: str) -> Any | None: route_url = str(route_payload.get("url") or "").strip() if not route_url: return None module_name = str(route_payload.get("module") or fallback_module or "").strip() - page = _find_website_page(env, website, xmlid="", url=route_url) + page = _find_website_page_by_url(env, website, url=route_url) if page: if bool(route_payload.get("published", True)): _write_existing_fields(page, {"is_published": True, "website_published": True}) @@ -218,8 +293,7 @@ def _verify_route(env: Any, website: Any, route_payload: dict[str, object], *, f return None except Exception as error: raise RuntimeError(f"Website bootstrap route {route_url!r} is not routable.") from error - print(f"Website bootstrap route verification skipped for {route_url}: ir.http._match unavailable.") - return None + raise RuntimeError(f"Website bootstrap route {route_url!r} is not verifiable; ir.http._match is unavailable.") def apply_website_bootstrap(env: Any, parsed_payload: dict[str, object] | None) -> None: @@ -256,18 +330,23 @@ def apply_website_bootstrap(env: Any, parsed_payload: dict[str, object] | None) if lang: website_values["default_lang_id"] = lang.id logo_path = _resolve_bootstrap_logo_path(website_payload.get("logo_path")) + logo_expected = logo_path is not None + logo_value = "" if logo_path is not None: _require_existing_fields(website, ("logo",), label="website logo") - website_values["logo"] = base64.b64encode(logo_path.read_bytes()).decode("ascii") + logo_value = base64.b64encode(logo_path.read_bytes()).decode("ascii") + website_values["logo"] = logo_value _write_existing_fields(website, website_values) if website_name: _assert_field_value(website, "name", website_name, label="website name") if canonical_url: _assert_field_value(website, "domain", _canonical_host(canonical_url), label="canonical domain") + if logo_expected: + _assert_binary_field_value(website, "logo", logo_value, label="website logo") homepage_url = str(website_payload.get("homepage_url") or "").strip() primary_page_xmlid = str(website_payload.get("primary_page_xmlid") or "").strip() - homepage_page = _find_website_page(env, website, xmlid=primary_page_xmlid, url=homepage_url) + homepage_page, primary_page_xmlid_found = _find_website_page(env, website, xmlid=primary_page_xmlid, url=homepage_url) if homepage_page: page_values: dict[str, object] = {"is_published": True, "website_published": True} if "website_id" in homepage_page._fields: @@ -276,6 +355,8 @@ def apply_website_bootstrap(env: Any, parsed_payload: dict[str, object] | None) elif primary_page_xmlid: raise RuntimeError(f"Website bootstrap primary page XML ID not found: {primary_page_xmlid}") _write_existing_fields(website, _homepage_values(website, homepage_url=homepage_url, homepage_page=homepage_page)) + final_homepage_url = homepage_url + final_homepage_page = homepage_page raw_routes_source = website_payload.get("routes_source") routes_source = raw_routes_source if isinstance(raw_routes_source, dict) else {} @@ -285,11 +366,28 @@ def apply_website_bootstrap(env: Any, parsed_payload: dict[str, object] | None) env, website, {"url": homepage_url, "module": fallback_module, "published": True}, fallback_module=fallback_module ) _write_existing_fields(website, _homepage_values(website, homepage_url=homepage_url, homepage_page=route_page)) + final_homepage_page = route_page for route_payload in website_payload.get("routes") or []: if isinstance(route_payload, dict): route_page = _verify_route(env, website, route_payload, fallback_module=fallback_module) if bool(route_payload.get("homepage")): route_url = str(route_payload.get("url") or "").strip() _write_existing_fields(website, _homepage_values(website, homepage_url=route_url, homepage_page=route_page)) - + final_homepage_url = route_url + final_homepage_page = route_page + + if final_homepage_url and "homepage_url" in website._fields: + _assert_field_value(website, "homepage_url", final_homepage_url, label="homepage URL") + if final_homepage_page and "homepage_id" in website._fields: + _assert_field_record_id(website, "homepage_id", final_homepage_page, label="homepage page") + + _print_bootstrap_readback( + website=website, + canonical_url=canonical_url, + homepage_url=final_homepage_url, + homepage_page=final_homepage_page, + primary_page_xmlid=primary_page_xmlid, + primary_page_xmlid_found=primary_page_xmlid_found, + logo_expected=logo_expected, + ) print("website_bootstrap_applied=true") diff --git a/tests/test_odoo_website_bootstrap.py b/tests/test_odoo_website_bootstrap.py index 0d1e23a..1fd2adb 100644 --- a/tests/test_odoo_website_bootstrap.py +++ b/tests/test_odoo_website_bootstrap.py @@ -1,9 +1,11 @@ from __future__ import annotations import importlib.util +import io import sys import types import unittest +from contextlib import redirect_stdout from pathlib import Path from typing import Any @@ -36,6 +38,7 @@ def __init__( self.writes: list[dict[str, object]] = [] self.truthy = truthy self.persist_writes = True + self.ignored_write_fields: set[str] = set() for field_name in self._fields: setattr(self, field_name, None) for field_name, value in (values or {}).items(): @@ -47,23 +50,34 @@ def __bool__(self) -> bool: def sudo(self) -> FakeRecord: return self + @property + def _name(self) -> str: + return str(getattr(self, "model_name", "")) + def write(self, values: dict[str, object]) -> None: self.writes.append(values) if not self.persist_writes: return for field_name, value in values.items(): + if field_name in self.ignored_write_fields: + continue setattr(self, field_name, value) class FakeModel: - def __init__(self, *, record: FakeRecord | None = None, fields: tuple[str, ...] = ()) -> None: + def __init__( + self, *, record: FakeRecord | None = None, fields: tuple[str, ...] = (), records: list[FakeRecord] | None = None + ) -> None: self.record = record if record is not None else FakeRecord(truthy=False) self._fields = set(fields) + self.records = records def sudo(self) -> FakeModel: return self def search(self, *unused_args: object, **unused_kwargs: object) -> FakeRecord: + if self.records is not None: + return self.records[0] if self.records else FakeRecord(truthy=False) return self.record def create(self, values: dict[str, object]) -> FakeRecord: @@ -96,6 +110,7 @@ def __init__(self) -> None: self.modules = FakeModel(record=FakeRecord(fields=(), truthy=True)) self.pages = FakeModel(record=FakeRecord(fields=(), truthy=False)) self.langs = FakeModel(record=FakeRecord(fields=(), truthy=False)) + self.refs: dict[str, FakeRecord] = {} self.registry = {"website": object()} def __getitem__(self, model_name: str) -> Any: @@ -108,9 +123,8 @@ def __getitem__(self, model_name: str) -> Any: "ir.http": FakeModel(record=FakeRecord()), }[model_name] - @staticmethod - def ref(*unused_args: object, **unused_kwargs: object) -> None: - return None + def ref(self, xmlid: str, *unused_args: object, **unused_kwargs: object) -> FakeRecord | None: + return self.refs.get(xmlid) class WebsiteBootstrapHelperTests(unittest.TestCase): @@ -163,6 +177,138 @@ def test_config_parameter_web_base_url_supplies_canonical_when_bootstrap_payload self.assertEqual(env.website.domain, "cm-website-testing.example.com") self.assertEqual(env.website.name, "Cell Mechanic") + def test_page_backed_homepage_requires_primary_page_and_persists_it(self) -> None: + env = FakeEnv() + page = FakeRecord( + record_id=42, + fields=("is_published", "website_published", "website_id"), + values={"model_name": "website.page"}, + ) + env.refs["cm_website.website_page_cell_mechanic"] = page + payload = { + "config_parameters": [ + { + "key": "web.base.url", + "value": {"source": "literal", "value": "https://cm-website-testing.example.com"}, + } + ], + "website_bootstrap": { + "name": "Cell Mechanic", + "homepage_url": "/cell-mechanic", + "primary_page_xmlid": "cm_website.website_page_cell_mechanic", + "routes_source": {"module": "cm_website"}, + }, + } + + website_bootstrap.apply_website_bootstrap(env, payload) + + self.assertEqual(env.website.homepage_id, page.id) + self.assertEqual(env.website.homepage_url, "/cell-mechanic") + self.assertIn({"is_published": True, "website_published": True, "website_id": 1}, page.writes) + + def test_missing_primary_page_fails_before_delegating_to_installed_module(self) -> None: + env = FakeEnv() + payload = { + "config_parameters": [ + { + "key": "web.base.url", + "value": {"source": "literal", "value": "https://cm-website-testing.example.com"}, + } + ], + "website_bootstrap": { + "name": "Cell Mechanic", + "homepage_url": "/cell-mechanic", + "primary_page_xmlid": "cm_website.website_page_cell_mechanic", + "routes_source": {"module": "cm_website"}, + }, + } + + with self.assertRaisesRegex(RuntimeError, "primary page XML ID not found"): + website_bootstrap.apply_website_bootstrap(env, payload) + + def test_bad_primary_page_xmlid_fails_even_when_url_fallback_page_exists(self) -> None: + env = FakeEnv() + env.pages = FakeModel( + record=FakeRecord( + record_id=43, + fields=("is_published", "website_published", "website_id"), + values={"model_name": "website.page"}, + ), + fields=("website_id",), + ) + payload = { + "website_bootstrap": { + "name": "Cell Mechanic", + "canonical_url": "https://cm-website-testing.example.com", + "homepage_url": "/cell-mechanic", + "primary_page_xmlid": "cm_website.bad_page_xmlid", + }, + } + + with self.assertRaisesRegex(RuntimeError, "primary page XML ID not found"): + website_bootstrap.apply_website_bootstrap(env, payload) + + def test_route_homepage_readback_reports_final_route_homepage(self) -> None: + env = FakeEnv() + route_page = FakeRecord( + record_id=44, + fields=("is_published", "website_published", "website_id"), + values={"model_name": "website.page"}, + ) + env.pages = FakeModel(record=route_page, fields=("website_id",)) + payload = { + "website_bootstrap": { + "name": "OPW", + "canonical_url": "https://opw-testing.example.com", + "routes": [ + { + "name": "Shop", + "url": "/shop", + "published": True, + "homepage": True, + } + ], + } + } + + output = io.StringIO() + with redirect_stdout(output): + website_bootstrap.apply_website_bootstrap(env, payload) + + self.assertEqual(env.website.homepage_id, route_page.id) + self.assertEqual(env.website.homepage_url, "/shop") + self.assertIn("website_bootstrap_homepage_url_matches=true", output.getvalue()) + self.assertIn("website_bootstrap_homepage_matches_page=true", output.getvalue()) + + def test_logo_readback_mismatch_fails_before_success_marker(self) -> None: + env = FakeEnv() + env.website.logo = "existing-logo" + env.website.ignored_write_fields.add("logo") + logo_path = Path(__file__) + payload = { + "website_bootstrap": { + "name": "Cell Mechanic", + "canonical_url": "https://cm-website-testing.example.com", + "logo_path": str(logo_path), + } + } + + with self.assertRaisesRegex(RuntimeError, "failed to persist website logo"): + website_bootstrap.apply_website_bootstrap(env, payload) + + def test_homepage_url_without_page_or_module_fails_when_route_is_not_verifiable(self) -> None: + env = FakeEnv() + payload = { + "website_bootstrap": { + "name": "Cell Mechanic", + "canonical_url": "https://cm-website-testing.example.com", + "homepage_url": "/cell-mechanic", + } + } + + with self.assertRaisesRegex(RuntimeError, "route '/cell-mechanic' is not verifiable"): + website_bootstrap.apply_website_bootstrap(env, payload) + def test_missing_visible_website_fields_fail_before_success_marker(self) -> None: env = FakeEnv() env.website = FakeRecord(fields=("homepage_id", "homepage_url"))