add website_membership_signup_required: force to create user before add to cart a membership product

This commit is contained in:
Luis 2026-07-07 10:27:13 +02:00
parent 80f24bcc46
commit c4d32c6add
22 changed files with 1202 additions and 0 deletions

View file

@ -0,0 +1,4 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import test_membership_signup
from . import test_membership_signup_tour

View file

@ -0,0 +1,138 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import werkzeug.datastructures
from odoo.fields import Date
from odoo.addons.website_membership_signup_required.controllers.main import (
AuthSignupHomeMembership as AuthSignupHome,
)
from odoo.addons.website.tools import MockRequest
from odoo.tests import TransactionCase, tagged
@tagged("post_install", "-at_install")
class TestMembershipSignup(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.website = cls.env.ref("website.default_website")
cls.website.auth_signup_uninvited = "b2b"
today = Date.to_date(Date.today())
cls.membership_product = cls.env["product.product"].create({
"name": "Membership Card",
"membership": True,
"list_price": 50.0,
"membership_date_from": today.replace(month=1, day=1),
"membership_date_to": today.replace(month=12, day=31),
})
cls.regular_product = cls.env["product.product"].create({
"name": "Regular Mug",
"membership": False,
"list_price": 10.0,
})
cls.no_user_partner = cls.env["res.partner"].create({
"firstname": "Backend",
"lastname": "Partner",
})
# -- RF-07 / RF-default ------------------------------------------------
def test_01_website_default_flag(self):
new_website = self.env["website"].create({"name": "Aux site"})
self.assertTrue(new_website.membership_signup_required)
# -- RF-01 helper -------------------------------------------------------
def test_02_is_required_membership_product_anonymous(self):
self.assertTrue(
self.website._membership_signup_required_for(
self.membership_product.id, is_public=True
)
)
# -- RF-05 -------------------------------------------------------------
def test_03_is_required_non_membership_product(self):
self.assertFalse(
self.website._membership_signup_required_for(
self.regular_product.id, is_public=True
)
)
# -- RF-06 -------------------------------------------------------------
def test_04_is_required_authenticated_user(self):
self.assertFalse(
self.website._membership_signup_required_for(
self.membership_product.id, is_public=False
)
)
# -- RF-07 -------------------------------------------------------------
def test_05_is_required_disabled_on_website(self):
self.website.membership_signup_required = False
self.assertFalse(
self.website._membership_signup_required_for(
self.membership_product.id, is_public=True
)
)
# -- RF-03 -------------------------------------------------------------
def test_06_signup_scope_override_with_marker(self):
Users = self.env["res.users"]
# No marker -> delegates to super (b2b because of setUpClass).
with MockRequest(self.env, website=self.website) as mock_request:
mock_request.httprequest.args = werkzeug.datastructures.MultiDict()
self.assertEqual(Users._get_signup_invitation_scope(), "b2b")
# Query param marker -> b2c.
with MockRequest(self.env, website=self.website) as mock_request:
mock_request.httprequest.args = werkzeug.datastructures.MultiDict(
{"membership_signup": "1"}
)
self.assertEqual(Users._get_signup_invitation_scope(), "b2c")
# Session flag marker -> b2c.
with MockRequest(self.env, website=self.website) as mock_request:
mock_request.httprequest.args = werkzeug.datastructures.MultiDict()
mock_request.session["membership_signup_active"] = True
self.assertEqual(Users._get_signup_invitation_scope(), "b2c")
# Marker removed again -> back to b2b.
with MockRequest(self.env, website=self.website) as mock_request:
mock_request.httprequest.args = werkzeug.datastructures.MultiDict()
self.assertEqual(Users._get_signup_invitation_scope(), "b2b")
# -- RF-09 -------------------------------------------------------------
def test_07_prepare_signup_values_firstname_lastname(self):
controller = AuthSignupHome()
qcontext = {
"login": "joohn.doe@example.com",
"password": "verystrongpwd",
"confirm_password": "verystrongpwd",
"firstname": "John",
"lastname": "Doe",
}
with MockRequest(self.env, website=self.website):
values = controller._prepare_signup_values(qcontext)
self.assertEqual(values.get("firstname"), "John")
self.assertEqual(values.get("lastname"), "Doe")
self.assertNotIn("name", values)
# -- RF-08 -------------------------------------------------------------
def test_08_backend_sale_order_with_membership_product_without_user(self):
order = self.env["sale.order"].create({
"partner_id": self.no_user_partner.id,
"order_line": [(0, 0, {
"product_id": self.membership_product.id,
"product_uom_qty": 1.0,
})],
})
self.assertTrue(order)
self.assertEqual(order.order_line.product_id, self.membership_product)
# Confirming the SO drives membership creation (core `membership`).
order.action_confirm()
self.assertEqual(order.state, "sale")

View file

@ -0,0 +1,202 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import hashlib
import hmac
import re
import time
from urllib.parse import unquote
from odoo.fields import Date
from odoo.tests import HttpCase, tagged
@tagged("post_install", "-at_install")
class TestMembershipSignupTour(HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.website = cls.env.ref("website.default_website")
cls.website.auth_signup_uninvited = "b2b"
cls.website.membership_signup_required = True
today = Date.to_date(Date.today())
cls.membership_product = cls.env["product.template"].create({
"name": "Membership Test Product",
"membership": True,
"list_price": 60.0,
"is_published": True,
"sale_ok": True,
"website_id": cls.website.id,
"membership_date_from": today.replace(month=1, day=1),
"membership_date_to": today.replace(month=12, day=31),
})
cls.regular_product = cls.env["product.template"].create({
"name": "Regular Test Product",
"membership": False,
"list_price": 5.0,
"is_published": True,
"sale_ok": True,
"website_id": cls.website.id,
})
# Enable the custom/transfer payment provider if it is installed so the
# cart page renders fully (no checkout/payment is exercised by the
# tours, but it avoids warnings during cart rendering).
provider = cls.env.ref("payment.payment_provider_transfer", raise_if_not_found=False)
if provider and provider.state != "enabled":
provider.write({"state": "enabled", "is_published": True})
def test_01_membership_signup_flow(self):
self.start_tour(
self.env["website"].get_client_action_url("/shop"),
"website_membership_signup_required_tour",
login=None,
)
# The tour submitted the signup form, so a new portal user must exist
# and the cart of that user must contain the membership product.
users = self.env["res.users"].search([
("login", "like", "membership.tour.%@test.example.com"),
])
self.assertTrue(users, "Tour should have created a user via /web/signup")
first = users.sorted("create_date")[-1]
self.assertTrue(first.partner_id.firstname)
self.assertTrue(first.partner_id.lastname)
carts = self.env["sale.order"].search([
("partner_id", "=", first.partner_id.id),
("state", "=", "draft"),
])
self.assertTrue(carts)
order_lines = carts.mapped("order_line").filtered(
lambda l: l.product_id.product_tmpl_id == self.membership_product
)
self.assertTrue(order_lines, "Cart must contain the membership product")
def test_02_non_membership_no_intercept(self):
self.start_tour(
self.env["website"].get_client_action_url("/shop"),
"website_membership_signup_required_non_intercept_tour",
login=None,
)
# -- HTTP-level tests (no Chrome) --------------------------------------
_CSRF_RE = re.compile(r'name="csrf_token"\s+value="([^"]+)"')
def _compute_csrf_token(self):
"""Manually compute a valid csrf token bound to the opener's current
session_id cookie. Mirrors `request.csrf_token()` so the test client
does not need to scrape the rendered HTML (which some addons strip for
specific authenticated users).
"""
sid = self.opener.cookies.get("session_id")
if not sid:
return None
secret = self.env["ir.config_parameter"].sudo().get_param("database.secret")
max_ts = int(time.time() + 31536000) # 1y, matches Odoo's default.
msg = f"{sid}{max_ts}".encode("utf-8")
hm = hmac.new(secret.encode("ascii"), msg, hashlib.sha1).hexdigest()
return f"{hm}o{max_ts}"
def _cart_update(self, product, add_qty="1"):
"""POST to /shop/cart/update with a valid csrf token. The csrf token
is scraped from the rendered product page, with a fallback computed
directly from the opener's session_id cookie (covers sessions whose
rendered page strips the form for the logged user).
"""
product_url = "/shop/%s" % self.env["ir.http"]._slug(product)
page = self.url_open(product_url, allow_redirects=True)
self.assertEqual(
page.status_code, 200,
"Product page must render (got %s for %s)" % (page.status_code, product_url),
)
match = self._CSRF_RE.search(page.text)
csrf_token = match.group(1) if match else self._compute_csrf_token()
self.assertTrue(
csrf_token,
"Could not obtain a csrf_token (rendered page or session-based).",
)
return self.url_open(
"/shop/cart/update",
data={
"product_id": str(product.product_variant_id.id),
"add_qty": add_qty,
"csrf_token": csrf_token,
},
allow_redirects=False,
)
def _assert_redirect(self, resp):
self.assertTrue(
300 <= resp.status_code < 400,
"Expected a HTTP redirect, got %s. Location: %s"
% (resp.status_code, resp.headers.get("Location", "")),
)
def test_03_anon_membership_redirect(self):
"""RF-01 / RF-02: anonymous POST /shop/cart/update for a membership
product must redirect to /web/signup?membership_signup=1&redirect=...
without adding the product to the cart.
"""
resp = self._cart_update(self.membership_product)
self._assert_redirect(resp)
# The `Location` header is URL-encoded, so any path segment with
# non-safe chars (here the product slug) arrives percent-encoded.
# Assert on stable substrings that do not depend on the encoding of
# each part of the URL (RF-02).
location = resp.headers.get("Location", "")
self.assertIn("/web/signup", location)
self.assertIn("membership_signup=1", location)
self.assertIn("redirect=", location)
self.assertIn("membership-test-product", unquote(location))
def test_04_anon_regular_no_redirect(self):
"""RF-05: anonymous POST /shop/cart/update for a non-membership
product must follow the core flow (redirect to /shop/cart).
"""
resp = self._cart_update(self.regular_product)
self._assert_redirect(resp)
location = resp.headers.get("Location", "")
self.assertIn("/shop/cart", location)
self.assertNotIn("/web/signup", location)
def test_05_authenticated_membership_no_redirect(self):
"""RF-06: an authenticated (non-public) user adding a membership
product must NOT be redirected to /web/signup.
"""
self.authenticate("admin", "admin")
resp = self._cart_update(self.membership_product)
self._assert_redirect(resp)
location = resp.headers.get("Location", "")
self.assertIn("/shop/cart", location)
self.assertNotIn("/web/signup", location)
def test_06_signup_marker_page_renders(self):
"""RF-03: GET /web/signup?membership_signup=1&redirect=/shop/... must
return 200 (not 404) on a b2b website thanks to the conditional
override of `_get_signup_invitation_scope`.
"""
product_url = "/shop/%s" % self.env["ir.http"]._slug(self.membership_product)
params = {"membership_signup": "1", "redirect": product_url}
resp = self.url_open(
"/web/signup?" + "&".join(f"{k}={v}" for k, v in params.items()),
allow_redirects=False,
)
self.assertIn(resp.status_code, (200, 302))
if resp.status_code == 302:
# Could be redirected to login if a session already exists.
self.assertNotIn("web/login", resp.headers.get("Location", ""))
def test_07_disabled_intercept_anon_membership(self):
"""RF-07: with `membership_signup_required = False` on the website, an
anonymous POST /shop/cart/update for a membership product must follow
the core flow (no redirect to /web/signup).
"""
self.website.membership_signup_required = False
try:
resp = self._cart_update(self.membership_product)
self._assert_redirect(resp)
location = resp.headers.get("Location", "")
self.assertIn("/shop/cart", location)
self.assertNotIn("/web/signup", location)
finally:
self.website.membership_signup_required = True