From 3e4bd5e5dbaa6f32db94d5dd6094decc58fde0d4 Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Mon, 17 Aug 2026 17:24:31 +0200 Subject: [PATCH] [ADD] website_sale_aplicoop: restore the test coverage lost with the dead code The dead-code cleanup dropped 11 test files that were never wired into tests/__init__.py. Reviewing what they covered turned up real holes, so the worthwhile ones come back, rewritten against the current schema. Blacklists were the serious gap: product, supplier and category exclusions have absolute priority over product discovery and nothing exercised them. The old file had two separate defects. Its supplier fixtures wrote `main_seller_id` directly, but product_main_seller computes that field from `variant_seller_ids`, so the compute reset it to False and the blacklist had nothing to exclude; they now create real supplierinfo records. Worse, four whole classes asserted against `group_order.product_ids` -- the m2m *input* -- instead of the discovery result, so they set `category_ids` and then checked a field they never touched. Those go through `_get_products_for_group_order` now, and three tests that had no assertions at all got some. The remaining three failures were test bugs too, all Odoo 17->18 leftovers: * Date cases assumed `pickup_date` derives from `start_date`. The chain is cutoff -> pickup -> delivery, and a recurring order whose start date has passed rolls forward to the current cycle, so a 2024 order has no 2024 pickup. The new file anchors on future dates and finds the next 29 February dynamically, with a class documenting the roll-forward itself. * `/eskaera/labels` is `type="json"`; the old test hit it with a plain GET and read the resulting 400 as a bug. It is called over JSON-RPC now, and a test pins the 400 so nobody repeats it. Also `uom.uom.categ` -> `uom.category`. * `price_include` is computed in 18.0, so fixtures must set `price_include_override`. On top of that `_get_price` filters taxes by company and defaults to `env.company`, not the fixture's, which left the tax list empty -- `tax_included` was False for the wrong reason. Two of the portal tests were passing for the wrong reason as well: the access guard bounced them to /eskaera, which also answers 200. Membership has to be set from the member side with `is_group`, and a new test checks the final URL rather than the status alone. Each fixture that can silently build the wrong thing now carries a guard test. Left out on purpose: three files were unimplemented placeholders, and test_draft_persistence still deserves recovering (see the notes file). Co-Authored-By: Claude Opus 5 --- .../COBERTURA_TESTS_PENDIENTE.md | 147 +++ website_sale_aplicoop/tests/__init__.py | 4 + .../tests/test_date_edge_cases.py | 280 ++++++ .../tests/test_portal_routes.py | 207 +++++ .../tests/test_price_with_taxes_included.py | 310 +++++++ .../tests/test_product_discovery.py | 869 ++++++++++++++++++ 6 files changed, 1817 insertions(+) create mode 100644 website_sale_aplicoop/COBERTURA_TESTS_PENDIENTE.md create mode 100644 website_sale_aplicoop/tests/test_date_edge_cases.py create mode 100644 website_sale_aplicoop/tests/test_portal_routes.py create mode 100644 website_sale_aplicoop/tests/test_price_with_taxes_included.py create mode 100644 website_sale_aplicoop/tests/test_product_discovery.py diff --git a/website_sale_aplicoop/COBERTURA_TESTS_PENDIENTE.md b/website_sale_aplicoop/COBERTURA_TESTS_PENDIENTE.md new file mode 100644 index 0000000..d7aebc8 --- /dev/null +++ b/website_sale_aplicoop/COBERTURA_TESTS_PENDIENTE.md @@ -0,0 +1,147 @@ +# Cobertura de tests — website_sale_aplicoop + +Estado tras recuperar y reescribir los tests que se habían perdido con la limpieza +de código muerto (commit `b3999e2`). + +La suite quedó en verde y creció en unos 90 tests respecto a los 270 que había +tras la limpieza. + +Los ficheros originales, si alguna vez hacen falta: + +```bash +git show b3999e2^:website_sale_aplicoop/tests/.py +``` + +--- + +## Recuperado y arreglado + +### 1. `test_product_discovery.py` — blacklists y descubrimiento (46 tests) + +Era el hueco grave: **ningún test cubría las tres blacklists**, que son lógica viva +con "prioridad absoluta" sobre el descubrimiento +([group_order.py:571-627](models/group_order.py#L571-L627)). + +Dos bugs distintos en el fichero original: + +- **`TestSupplierBlacklist` (6 fallos)**: el fixture escribía `main_seller_id` en el + `create` del template, pero en el addon OCA `product_main_seller` ese campo es + *computed + store* derivado de `variant_seller_ids` — el compute lo pisaba a `False`, + los productos quedaban sin proveedor principal y la blacklist no tenía nada que + excluir. Arreglado creando `product.supplierinfo` de verdad, más un test-guarda + (`test_main_seller_is_computed_from_supplierinfo`) para que no vuelva a pasar en + silencio. +- **Cuatro clases enteras no probaban el descubrimiento**: hacían aserciones contra + `group_order.product_ids`, que es el campo m2m de *entrada*, no el resultado. Es + decir, asignaban `category_ids` y comprobaban `product_ids`, así que sólo podían + fallar; y algunas "pasaban" por el motivo equivocado (el campo estaba vacío de + todos modos). Tres tests no tenían ni una aserción. Todo reescrito contra + `_get_products_for_group_order`. + +Añadido de paso: productos archivados, pedido sin ninguna fuente, id inexistente, y +el contrato de ordenación real (`is_out_of_stock`, `website_sequence`, nombre). + +### 2. `test_date_edge_cases.py` — calendario (12 tests) + +Sustituye al antiguo `test_edge_cases.py`. Los 4 fallos que había eran **del test**, +no de producción: daban por hecho que `pickup_date` se calcula desde `start_date`, +cuando en realidad la cadena es `cutoff_date` → `pickup_date` → `delivery_date`, y +para un pedido recurrente con `start_date` pasada la referencia pasa a ser **hoy** +(el ciclo avanza al actual). Un pedido de 2024 no tiene fecha de recogida en 2024. + +Los nuevos anclan en fechas **futuras** para que el avance de ciclo no mueva el caso +bajo los pies, y buscan el próximo 29-F dinámicamente (no hay fechas quemadas que +caduquen). Cubren: recogida justo en 29-F, ciclo que cruza el 29-F, entrega el 1-M, +cruce de mes y de año, último día de mes, rejilla mensual desde un ancla de 31-E y +en febrero bisiesto. `TestRollForwardBehaviour` documenta el avance de ciclo, que es +justo lo que el test viejo malinterpretaba. + +### 3. `test_portal_routes.py` — humo HTTP con usuario portal (8 tests) + +Une los tres ficheros portal borrados. Los fallos eran del test: + +- **`/eskaera/labels` devolvía 400**: la ruta es `type="json"` y el test la llamaba + con un GET plano. Odoo responde 400 correctamente. Ahora se llama por JSON-RPC + (`make_jsonrpc_request`), y hay un test que fija ese 400 del GET para que nadie + repita el error. +- **`uom.uom.categ` no existe** en Odoo 18 (es `uom.category`), y `factor_inv` → + `factor`. +- **La afiliación al grupo no se aplicaba**: hay que poner `is_group=True` en el + grupo y asignar `group_ids` desde el *miembro* (es el idioma que usa + `test_record_rules.py`). Sin eso el guard rebotaba a `/eskaera`. + +Ojo con esto último: el smoke test original sólo miraba `status_code == 200`, y el +rebote a la lista **también** devuelve 200 — pasaba sin que el socio entrase nunca a +la tienda. Añadido `test_shop_page_is_not_bounced_to_the_list`, que comprueba la URL +final. + +### 4. `test_price_with_taxes_included.py` — impuestos (14 tests) + +Dos causas, ambas del test: + +- **`price_include` es computed en Odoo 18**, derivado de `price_include_override` + (Selection `tax_included`/`tax_excluded`). El fixture escribía `price_include=True` + y se descartaba en silencio. Añadido `test_price_include_is_driven_by_the_override` + como guarda. +- **`_get_price` filtra los impuestos por compañía**, y su parámetro `company` cae + por defecto en `self.env.company`. Como el fixture crea una compañía propia, la + lista de impuestos salía **vacía** y `tax_included` era siempre `False`. Nótese que + `test_oca_get_price_returns_base_without_tax` pasaba por este motivo equivocado. + Ahora todas las llamadas van por un helper que pasa `company=self.company`. + +--- + +## Sin recuperar (no había nada que rescatar) + +Tres ficheros eran plantillas sin implementar — setUp seguido de métodos con +`# Placeholder: will be implemented...` y cero aserciones, o aserciones sobre +diccionarios simulados en vez de llamar al controlador: + +- `test_endpoints.py` +- `test_helper_methods_phase1.py` +- `test_phase2_eskaera_shop.py` + +--- + +## Pendiente + +### `test_draft_persistence.py` — persistencia de borradores + +Lo único de los 11 ficheros que aún no se ha rehecho. Cubría casos que hoy no cubre +nadie (`test_save_order_endpoints.py` y `test_group_order_status_endpoint.py` cubren +el guardado y el ciclo, no esto): + +- **el precio del borrador es una foto fija**: cambiar `list_price` después no altera + el borrador ya guardado. El más valioso — es una garantía de negocio real (al socio + se le respeta el precio que vio) y ahora mismo nada la protege. +- borrador con producto archivado después de guardarlo +- borrador de un pedido ya cerrado / de hace 6 meses +- un borrador no es visible para otro usuario +- recuento de borradores por usuario + +Sólo 2 de sus 14 tests fallaban, así que la recuperación debería ser barata. + +### Huecos menores + +- Transiciones de estado **ilegales** (draft→closed, cancelled→open). La suite cubre + las legales en `test_group_order.py`. +- `available_products_count` fuera del contexto de blacklists (dentro de ellas ya + está cubierto). + +Nota: las constraints `_check_company_groups` y `_check_dates` **sí** están cubiertas +(`test_multi_company.py:147`, `test_group_order.py:66`) — no son hueco. + +--- + +## Conclusión sobre los "posibles bugs de producción" + +Los tres que quedaron marcados para investigar (fechas 29-F, `/eskaera/labels` 400, +`tax_included`) resultaron ser **todos bugs de los tests**, en su mayoría restos de la +migración 17→18: `is_supplier`, `main_seller_id`, `uom.uom.categ`, `factor_inv`, +`price_include`. No apareció ningún bug de producción. + +Lo que sí apareció, y es más incómodo, son **tests que pasaban por el motivo +equivocado** — el smoke test del portal que se conformaba con el rebote, y +`test_oca_get_price_returns_base_without_tax` que verificaba `tax_included == False` +sobre una lista de impuestos vacía. Por eso cada arreglo lleva ahora un test-guarda +que falla si el fixture deja de construir lo que dice construir. diff --git a/website_sale_aplicoop/tests/__init__.py b/website_sale_aplicoop/tests/__init__.py index 0938e6d..5afc215 100644 --- a/website_sale_aplicoop/tests/__init__.py +++ b/website_sale_aplicoop/tests/__init__.py @@ -19,3 +19,7 @@ from . import test_group_order_status_endpoint # noqa: F401 from . import test_home_delivery # noqa: F401 from . import test_forecasted_stock # noqa: F401 from . import test_online_payment # noqa: F401 +from . import test_product_discovery # noqa: F401 +from . import test_date_edge_cases # noqa: F401 +from . import test_portal_routes # noqa: F401 +from . import test_price_with_taxes_included # noqa: F401 diff --git a/website_sale_aplicoop/tests/test_date_edge_cases.py b/website_sale_aplicoop/tests/test_date_edge_cases.py new file mode 100644 index 0000000..95585ac --- /dev/null +++ b/website_sale_aplicoop/tests/test_date_edge_cases.py @@ -0,0 +1,280 @@ +# Copyright 2025 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +"""Calendar edge cases for the cutoff/pickup/delivery date chain. + +The date chain is `cutoff_date` -> `pickup_date` -> `delivery_date`: + +- `cutoff_date` is the next occurrence of `cutoff_day`, measured from + `start_date` when it is in the future, or from **today** when `start_date` + already passed. Recurring orders therefore roll forward to the current + cycle instead of staying on the cycle they were created in. +- `pickup_date` is the next occurrence of `pickup_day` *strictly after* + `cutoff_date`. +- `delivery_date` is `pickup_date` + 1 day. + +Every fixture below anchors on a **future** start date so the roll-forward +does not move the cycle under the test, which keeps the arithmetic exact and +the assertions stable over time. `test_past_weekly_order_rolls_forward` +covers the roll-forward itself. +""" + +from datetime import date +from datetime import timedelta + +from dateutil.relativedelta import relativedelta + +from odoo.tests.common import TransactionCase + + +class DateEdgeCaseCommon: + """Helpers to build orders anchored on chosen calendar dates.""" + + def setUp(self): + super().setUp() + self.group = self.env["res.partner"].create( + { + "name": "Test Group", + "is_company": True, + } + ) + + def _create_order(self, start_date, pickup_day, cutoff_day, **overrides): + vals = { + "name": "Date Edge Case Order", + "group_ids": [(6, 0, [self.group.id])], + "type": "regular", + "start_date": start_date, + "end_date": start_date + timedelta(days=7), + "period": "weekly", + "pickup_day": str(pickup_day), + "cutoff_day": str(cutoff_day), + } + vals.update(overrides) + return self.env["group.order"].create(vals) + + @staticmethod + def _next_leap_day(reference): + """First 29 February strictly after `reference`.""" + year = reference.year + while True: + year += 1 + try: + candidate = date(year, 2, 29) + except ValueError: + continue + if candidate > reference: + return candidate + + @staticmethod + def _expected_cutoff(start_date, cutoff_day): + """Mirror of `_compute_cutoff_date` for a future weekly start.""" + days_ahead = cutoff_day - start_date.weekday() + if days_ahead < 0: + days_ahead += 7 + return start_date + timedelta(days=days_ahead) + + @staticmethod + def _expected_pickup(cutoff_date, pickup_day): + """Mirror of `_compute_pickup_date`: strictly after the cutoff.""" + days_ahead = pickup_day - cutoff_date.weekday() + if days_ahead <= 0: + days_ahead += 7 + return cutoff_date + timedelta(days=days_ahead) + + +class TestLeapYearHandling(DateEdgeCaseCommon, TransactionCase): + """A 29 February inside the cycle must not shift the dates.""" + + def test_pickup_lands_on_leap_day(self): + """A pickup that falls exactly on 29 February is computed as such.""" + leap_day = self._next_leap_day(date.today()) + # Anchor the cycle so the cutoff is the day before the leap day; the + # next pickup weekday is then the leap day itself. + cutoff = leap_day - timedelta(days=1) + start = cutoff - timedelta(days=1) + + order = self._create_order( + start_date=start, + pickup_day=leap_day.weekday(), + cutoff_day=cutoff.weekday(), + ) + + self.assertEqual(order.cutoff_date, cutoff) + self.assertEqual(order.pickup_date, leap_day) + self.assertEqual(order.pickup_date.day, 29) + self.assertEqual(order.pickup_date.month, 2) + + def test_cycle_spans_the_leap_day(self): + """A cycle crossing 29 February keeps the 7-day pickup spacing.""" + leap_day = self._next_leap_day(date.today()) + start = leap_day - timedelta(days=3) + + order = self._create_order( + start_date=start, + pickup_day=(start.weekday() + 4) % 7, + cutoff_day=start.weekday(), + ) + + # cutoff is the start day itself (days_ahead == 0 is allowed) + self.assertEqual(order.cutoff_date, start) + self.assertEqual(order.pickup_date, start + timedelta(days=4)) + # The leap day is inside the cycle, so it was counted as a real day. + self.assertLess(order.cutoff_date, leap_day) + self.assertGreater(order.pickup_date, leap_day) + + def test_delivery_after_leap_day_pickup(self): + """Delivery is pickup + 1 day even when pickup is 29 February.""" + leap_day = self._next_leap_day(date.today()) + cutoff = leap_day - timedelta(days=1) + start = cutoff - timedelta(days=1) + + order = self._create_order( + start_date=start, + pickup_day=leap_day.weekday(), + cutoff_day=cutoff.weekday(), + ) + + self.assertEqual(order.pickup_date, leap_day) + # 1 March in a leap year. + self.assertEqual(order.delivery_date, date(leap_day.year, 3, 1)) + + +class TestMonthAndYearBoundaries(DateEdgeCaseCommon, TransactionCase): + """Cycles crossing a month or year boundary.""" + + def _future_day(self, target_day, month_offset=1): + """A future date landing on `target_day` of some upcoming month.""" + candidate = date.today().replace(day=1) + relativedelta(months=month_offset) + return candidate.replace(day=target_day) + + def test_pickup_crosses_month_boundary(self): + """A pickup in the month after the cutoff is computed correctly.""" + # Anchor on the 28th so a few days forward always lands next month. + start = self._future_day(28) + pickup_day = (start.weekday() + 5) % 7 + + order = self._create_order( + start_date=start, + pickup_day=pickup_day, + cutoff_day=start.weekday(), + ) + + expected_pickup = start + timedelta(days=5) + self.assertEqual(order.cutoff_date, start) + self.assertEqual(order.pickup_date, expected_pickup) + self.assertNotEqual(order.pickup_date.month, order.cutoff_date.month) + + def test_pickup_crosses_year_boundary(self): + """A cycle spanning 31 December rolls into the next year.""" + today = date.today() + start = date(today.year + 1, 12, 29) + pickup_day = (start.weekday() + 4) % 7 + + order = self._create_order( + start_date=start, + pickup_day=pickup_day, + cutoff_day=start.weekday(), + ) + + self.assertEqual(order.cutoff_date, start) + self.assertEqual(order.pickup_date, start + timedelta(days=4)) + self.assertEqual(order.pickup_date.year, start.year + 1) + self.assertEqual(order.pickup_date.month, 1) + + def test_last_day_of_month_pickup(self): + """A pickup landing on the last day of the month is kept intact.""" + start = self._future_day(24) + last_day = start + relativedelta(day=31) + cutoff = last_day - timedelta(days=1) + + order = self._create_order( + start_date=start, + pickup_day=last_day.weekday(), + cutoff_day=cutoff.weekday(), + ) + + self.assertEqual(order.cutoff_date, cutoff) + self.assertEqual(order.pickup_date, last_day) + # Next day already belongs to the following month. + self.assertEqual(order.delivery_date.day, 1) + + +class TestMonthlyRecurrenceBoundaries(DateEdgeCaseCommon, TransactionCase): + """The monthly grid must survive short months and leap years.""" + + def test_monthly_grid_from_january_31_anchor(self): + """A 31 January anchor advances into February without overflowing.""" + today = date.today() + anchor = date(today.year + 1, 1, 31) + + order = self._create_order( + start_date=anchor, + pickup_day=(anchor.weekday() + 2) % 7, + cutoff_day=anchor.weekday(), + period="monthly", + end_date=anchor + relativedelta(months=6), + ) + + # First grid point is the anchor itself (already on cutoff_day). + self.assertEqual(order.cutoff_date, anchor) + self.assertEqual(order.pickup_date, anchor + timedelta(days=2)) + + def test_monthly_grid_lands_in_february_of_a_leap_year(self): + """A February cycle in a leap year stays inside February.""" + leap_day = self._next_leap_day(date.today()) + anchor = date(leap_day.year, 2, 1) + # Only meaningful while the anchor is still ahead of us. + if anchor <= date.today(): + self.skipTest("The next leap February already started") + + order = self._create_order( + start_date=anchor, + pickup_day=(anchor.weekday() + 1) % 7, + cutoff_day=anchor.weekday(), + period="monthly", + end_date=anchor + relativedelta(months=6), + ) + + self.assertEqual(order.cutoff_date, anchor) + self.assertEqual(order.cutoff_date.month, 2) + self.assertEqual(order.pickup_date, anchor + timedelta(days=1)) + + +class TestRollForwardBehaviour(DateEdgeCaseCommon, TransactionCase): + """Past-dated recurring orders move to the current cycle.""" + + def test_past_weekly_order_rolls_forward(self): + """A weekly order created long ago computes dates for today's cycle. + + This is the documented behaviour of `_compute_cutoff_date`: once + `start_date` is in the past the reference becomes today, so the order + never keeps stale dates from the cycle it was created in. + """ + today = date.today() + long_ago = today - relativedelta(years=2) + + order = self._create_order( + start_date=long_ago, + pickup_day=3, + cutoff_day=0, + end_date=long_ago + timedelta(days=7), + ) + + expected_cutoff = self._expected_cutoff(today, 0) + self.assertEqual(order.cutoff_date, expected_cutoff) + self.assertEqual(order.pickup_date, self._expected_pickup(expected_cutoff, 3)) + # The stale cycle is gone: nothing points back at the creation year. + self.assertGreaterEqual(order.cutoff_date, today) + self.assertGreater(order.pickup_date, today) + + def test_future_weekly_order_keeps_its_own_cycle(self): + """A future order is anchored on its start date, not on today.""" + start = date.today() + timedelta(days=30) + + order = self._create_order(start_date=start, pickup_day=3, cutoff_day=0) + + expected_cutoff = self._expected_cutoff(start, 0) + self.assertEqual(order.cutoff_date, expected_cutoff) + self.assertEqual(order.pickup_date, self._expected_pickup(expected_cutoff, 3)) + self.assertGreaterEqual(order.cutoff_date, start) diff --git a/website_sale_aplicoop/tests/test_portal_routes.py b/website_sale_aplicoop/tests/test_portal_routes.py new file mode 100644 index 0000000..cb36a40 --- /dev/null +++ b/website_sale_aplicoop/tests/test_portal_routes.py @@ -0,0 +1,207 @@ +# Copyright 2025 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +"""Smoke tests for the Eskaera pages as seen by a plain portal user. + +Covers that the main pages answer 200 and that reading a product's UoM for +display does not raise an AccessError for a portal user. + +`/eskaera/labels` and `/eskaera/i18n` are `type="json"` routes: a bare GET is +answered with 400 by design, so they are exercised through a JSON-RPC call. +""" + +from datetime import datetime +from datetime import timedelta + +from odoo.tests import tagged +from odoo.tests.common import HttpCase + + +class PortalRoutesCommon: + """Build a portal user that belongs to an open group order.""" + + def setUp(self): + super().setUp() + self.group = self.env["res.partner"].create( + { + "name": "Portal Routes Group", + "is_company": True, + "is_group": True, + "email": "routes-group@test.com", + } + ) + # The shop guard reads `partner_id.group_ids`, so the membership has + # to be set from the member side to be visible right away. + self.member_partner = self.env["res.partner"].create( + { + "name": "Routes Member", + "email": "routes-member@test.com", + "group_ids": [(6, 0, [self.group.id])], + } + ) + + # HttpCase.authenticate() wants the password, so reuse the login. + self.portal_login = "portal.routes@test.com" + self.portal_user = self.env["res.users"].create( + { + "name": "Portal Routes User", + "login": self.portal_login, + "password": self.portal_login, + "partner_id": self.member_partner.id, + "groups_id": [(4, self.env.ref("base.group_portal").id)], + } + ) + + start_date = datetime.now().date() + self.group_order = self.env["group.order"].create( + { + "name": "Routes Test Order", + "group_ids": [(6, 0, [self.group.id])], + "type": "regular", + "start_date": start_date, + "end_date": start_date + timedelta(days=7), + "period": "weekly", + "pickup_day": "3", + "cutoff_day": "0", + } + ) + self.group_order.action_open() + + def _login_portal(self): + self.authenticate(self.portal_login, self.portal_login) + + +@tagged("post_install", "-at_install") +class TestPortalGetRoutes(PortalRoutesCommon, HttpCase): + """The main GET pages answer 200 for a portal user.""" + + def test_portal_get_routes_return_200(self): + """Every public Eskaera page renders for a portal user.""" + self._login_portal() + + routes = [ + "/eskaera", + f"/eskaera/{self.group_order.id}", + f"/eskaera/{self.group_order.id}/checkout", + f"/eskaera/{self.group_order.id}/load-page?page=1", + ] + + for route in routes: + response = self.url_open(route, allow_redirects=True) + self.assertEqual( + response.status_code, 200, msg=f"Route {route} returned an error" + ) + + def test_shop_page_is_not_bounced_to_the_list(self): + """A member reaches the shop itself, not the "/eskaera" fallback. + + The access guard redirects non-members to the list page, which also + answers 200 -- so a plain status check would pass even when the member + never got in. + """ + self._login_portal() + + response = self.url_open( + f"/eskaera/{self.group_order.id}", allow_redirects=True + ) + + self.assertEqual(response.status_code, 200) + self.assertTrue( + response.url.endswith(f"/eskaera/{self.group_order.slug}"), + msg=f"Bounced to {response.url} instead of the shop page", + ) + + def test_slug_urls_answer_for_portal_user(self): + """The canonical slug URLs answer too, not just the numeric ones.""" + self._login_portal() + + for suffix in ("", "/checkout"): + route = f"/eskaera/{self.group_order.slug}{suffix}" + response = self.url_open(route, allow_redirects=True) + self.assertEqual( + response.status_code, 200, msg=f"Route {route} returned an error" + ) + + +@tagged("post_install", "-at_install") +class TestPortalLabelsEndpoint(PortalRoutesCommon, HttpCase): + """`/eskaera/labels` is a JSON-RPC endpoint, not a plain GET page.""" + + def test_labels_endpoint_returns_translations(self): + """A JSON-RPC call returns the label dictionary.""" + self._login_portal() + + labels = self.make_jsonrpc_request("/eskaera/labels") + + self.assertIsInstance(labels, dict) + # A few keys the checkout summary relies on. + for key in ("product", "quantity", "price", "subtotal", "total"): + self.assertIn(key, labels) + + def test_i18n_alias_returns_the_same_payload(self): + """`/eskaera/i18n` is an alias of `/eskaera/labels`.""" + self._login_portal() + + labels = self.make_jsonrpc_request("/eskaera/labels") + alias = self.make_jsonrpc_request("/eskaera/i18n") + + self.assertEqual(labels, alias) + + def test_labels_endpoint_is_public(self): + """The endpoint answers without logging in (auth="public").""" + labels = self.make_jsonrpc_request("/eskaera/labels") + + self.assertIsInstance(labels, dict) + self.assertTrue(labels) + + def test_plain_get_is_rejected(self): + """A bare GET is not a valid call for a JSON route. + + Guards the mistake this test file used to make: asserting 200 on a + plain GET against `type="json"`, which Odoo answers with 400. + """ + self._login_portal() + + response = self.url_open("/eskaera/labels", allow_redirects=True) + + self.assertEqual(response.status_code, 400) + + +@tagged("post_install", "-at_install") +class TestPortalProductUoMAccess(PortalRoutesCommon, HttpCase): + """Rendering the shop must not need UoM read rights beyond the portal's.""" + + def setUp(self): + super().setUp() + uom_category = self.env["uom.category"].create({"name": "Test UoM Cat"}) + self.uom = self.env["uom.uom"].create( + { + "name": "Test UoM", + "uom_type": "reference", + "factor": 1.0, + "category_id": uom_category.id, + } + ) + self.product = self.env["product.product"].create( + { + "name": "Portal UoM Product", + "type": "consu", + "list_price": 10.0, + "is_published": True, + "sale_ok": True, + "uom_id": self.uom.id, + "uom_po_id": self.uom.id, + } + ) + self.group_order.product_ids = [(4, self.product.id)] + + def test_portal_user_can_view_shop_with_uom(self): + """The shop page renders for a portal user with a custom UoM.""" + self._login_portal() + + response = self.url_open( + f"/eskaera/{self.group_order.id}", allow_redirects=True + ) + + self.assertEqual(response.status_code, 200) + self.assertIn("Portal UoM Product", response.text) diff --git a/website_sale_aplicoop/tests/test_price_with_taxes_included.py b/website_sale_aplicoop/tests/test_price_with_taxes_included.py new file mode 100644 index 0000000..3926448 --- /dev/null +++ b/website_sale_aplicoop/tests/test_price_with_taxes_included.py @@ -0,0 +1,310 @@ +# Copyright 2025 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +"""Price calculations around included/excluded taxes. + +Checks how `account.tax.compute_all` and the OCA helper +`product.product._get_price` (from `product_get_price_helper`) behave for the +shop, including the tax breakdown and fiscal-position mapping. + +Odoo 18 note: `account.tax.price_include` is a *computed* boolean derived from +`price_include_override` (and the company default). Tax fixtures must set +`price_include_override` — writing `price_include` directly is discarded. +""" + +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + + +@tagged("post_install", "-at_install") +class TestPriceWithTaxesIncluded(TransactionCase): + """Test that prices displayed include taxes.""" + + def setUp(self): + super().setUp() + + self.company = self.env["res.company"].create( + { + "name": "Test Company Tax Included", + } + ) + + tax_group = self.env["account.tax.group"].search( + [("company_id", "=", self.company.id)], limit=1 + ) + if not tax_group: + tax_group = self.env["account.tax.group"].create( + { + "name": "IVA", + "company_id": self.company.id, + } + ) + + country_es = self.env.ref("base.es") + + self.tax_21 = self._create_tax("IVA 21%", 21.0, tax_group, country_es) + self.tax_10 = self._create_tax("IVA 10%", 10.0, tax_group, country_es) + self.tax_21_included = self._create_tax( + "IVA 21% Incluido", 21.0, tax_group, country_es, included=True + ) + + self.category = self.env["product.category"].create( + { + "name": "Test Category Tax Included", + } + ) + + self.product_21 = self._create_product("Product With 21% Tax", self.tax_21) + self.product_10 = self._create_product("Product With 10% Tax", self.tax_10) + self.product_no_tax = self._create_product("Product Without Tax", None) + self.product_tax_included = self._create_product( + # 100 + 21% = 121, already carrying the tax. + "Product With Tax Included", + self.tax_21_included, + list_price=121.0, + ) + + self.pricelist = self.env["product.pricelist"].create( + { + "name": "Test Pricelist", + "company_id": self.company.id, + } + ) + + def _create_tax(self, name, amount, tax_group, country, included=False): + """Create a sale tax, choosing included/excluded via the override.""" + return self.env["account.tax"].create( + { + "name": name, + "amount": amount, + "amount_type": "percent", + "type_tax_use": "sale", + "price_include_override": ( + "tax_included" if included else "tax_excluded" + ), + "company_id": self.company.id, + "country_id": country.id, + "tax_group_id": tax_group.id, + } + ) + + def _create_product(self, name, tax, list_price=100.0): + return self.env["product.product"].create( + { + "name": name, + "list_price": list_price, + "categ_id": self.category.id, + "taxes_id": [(6, 0, [tax.id])] if tax else False, + "company_id": self.company.id, + } + ) + + def _company_taxes(self, product): + return product.taxes_id.filtered(lambda t: t.company_id == self.company) + + def _get_price(self, product, fposition=False): + """Call the OCA helper for the fixture company. + + `_get_price` defaults `company` to `self.env.company` and filters the + product taxes by it, so omitting it here would silently drop every tax + of the test company and make `tax_included` always False. + """ + return product._get_price( + qty=1.0, + pricelist=self.pricelist, + fposition=fposition, + company=self.company, + ) + + def _compute_all(self, product, base_price=100.0, taxes=None): + taxes = self._company_taxes(product) if taxes is None else taxes + return taxes.compute_all( + base_price, + currency=self.env.company.currency_id, + quantity=1.0, + product=product, + ) + + def test_price_include_is_driven_by_the_override(self): + """Guard: the fixtures really are included/excluded as intended. + + `price_include` is computed in Odoo 18, so a fixture that sets the + wrong field silently produces excluded taxes everywhere. + """ + self.assertFalse(self.tax_21.price_include) + self.assertFalse(self.tax_10.price_include) + self.assertTrue(self.tax_21_included.price_include) + + def test_price_with_21_percent_tax(self): + """Test that 21% tax is correctly added to base price.""" + result = self._compute_all(self.product_21) + + self.assertAlmostEqual( + result["total_included"], 121.0, places=2, msg="100 + 21% should be 121.0" + ) + + def test_price_with_10_percent_tax(self): + """Test that 10% tax is correctly added to base price.""" + result = self._compute_all(self.product_10) + + self.assertAlmostEqual( + result["total_included"], 110.0, places=2, msg="100 + 10% should be 110.0" + ) + + def test_price_without_tax(self): + """A product with no taxes keeps its base price.""" + self.assertFalse( + self._company_taxes(self.product_no_tax), "Product should have no taxes" + ) + + price_info = self._get_price(self.product_no_tax) + + self.assertAlmostEqual(price_info["value"], 100.0, places=2) + self.assertFalse(price_info["tax_included"]) + + def test_oca_get_price_returns_base_without_tax(self): + """OCA `_get_price` returns the base price for tax-excluded products.""" + price_info = self._get_price(self.product_21) + + self.assertAlmostEqual( + price_info["value"], + 100.0, + places=2, + msg="OCA _get_price should return base price without tax", + ) + self.assertFalse( + price_info["tax_included"], + msg="tax_included should be False for a tax-excluded tax", + ) + + def test_oca_get_price_with_included_tax(self): + """OCA `_get_price` flags tax_included for a tax-included product.""" + price_info = self._get_price(self.product_tax_included) + + self.assertAlmostEqual( + price_info["value"], + 121.0, + places=2, + msg="Price with included tax should stay at 121.0", + ) + self.assertTrue( + price_info["tax_included"], + msg="tax_included should be True for a tax-included tax", + ) + + def test_compute_all_with_multiple_taxes(self): + """Test tax calculation with multiple taxes.""" + product_multi = self.env["product.product"].create( + { + "name": "Product With Multiple Taxes", + "list_price": 100.0, + "categ_id": self.category.id, + "taxes_id": [(6, 0, [self.tax_21.id, self.tax_10.id])], + "company_id": self.company.id, + } + ) + + result = self._compute_all(product_multi) + + # 100 + 21 + 10 = 131.0 + self.assertAlmostEqual( + result["total_included"], 131.0, places=2, msg="100 + 21% + 10% = 131.0" + ) + + def test_compute_all_with_fiscal_position(self): + """A fiscal position remapping 21% to 10% changes the total.""" + fiscal_position = self.env["account.fiscal.position"].create( + { + "name": "Test Fiscal Position", + "company_id": self.company.id, + } + ) + self.env["account.fiscal.position.tax"].create( + { + "position_id": fiscal_position.id, + "tax_src_id": self.tax_21.id, + "tax_dest_id": self.tax_10.id, + } + ) + + mapped_taxes = fiscal_position.map_tax(self._company_taxes(self.product_21)) + + self.assertEqual(mapped_taxes, self.tax_10) + + result = self._compute_all(self.product_21, taxes=mapped_taxes) + + self.assertAlmostEqual( + result["total_included"], + 110.0, + places=2, + msg="Fiscal position should map to the 10% tax", + ) + + def test_get_price_applies_fiscal_position(self): + """`_get_price` honours the fiscal position it is handed.""" + fiscal_position = self.env["account.fiscal.position"].create( + { + "name": "Map To Included Tax", + "company_id": self.company.id, + } + ) + self.env["account.fiscal.position.tax"].create( + { + "position_id": fiscal_position.id, + "tax_src_id": self.tax_21.id, + "tax_dest_id": self.tax_21_included.id, + } + ) + + price_info = self._get_price(self.product_21, fposition=fiscal_position) + + # The mapped tax is tax-included, so the helper must say so. + self.assertTrue(price_info["tax_included"]) + + def test_tax_amount_details(self): + """Test that compute_all provides a detailed tax breakdown.""" + result = self._compute_all(self.product_21) + + self.assertIn("total_included", result) + self.assertIn("total_excluded", result) + self.assertIn("taxes", result) + + self.assertAlmostEqual(result["total_excluded"], 100.0, places=2) + self.assertAlmostEqual(result["total_included"], 121.0, places=2) + + self.assertEqual(len(result["taxes"]), 1) + self.assertAlmostEqual(result["taxes"][0]["amount"], 21.0, places=2) + + def test_included_tax_breakdown_keeps_the_gross_price(self): + """For an included tax, the gross price is the one the member sees.""" + result = self._compute_all(self.product_tax_included, base_price=121.0) + + self.assertAlmostEqual(result["total_included"], 121.0, places=2) + self.assertAlmostEqual(result["total_excluded"], 100.0, places=2) + self.assertAlmostEqual(result["taxes"][0]["amount"], 21.0, places=2) + + def test_zero_price_with_tax(self): + """Test tax calculation on a free product.""" + free_product = self._create_product( + "Free Product With Tax", self.tax_21, list_price=0.0 + ) + + result = self._compute_all(free_product, base_price=0.0) + + self.assertAlmostEqual( + result["total_included"], + 0.0, + places=2, + msg="Free product with tax should still be free", + ) + + def test_high_precision_price_with_tax(self): + """Test tax calculation with high precision prices.""" + precise_product = self._create_product( + "Precise Price Product", self.tax_21, list_price=99.99 + ) + + result = self._compute_all(precise_product, base_price=99.99) + + # 99.99 + 21% = 120.9879 -> 120.99 + self.assertAlmostEqual(result["total_included"], 99.99 * 1.21, places=2) diff --git a/website_sale_aplicoop/tests/test_product_discovery.py b/website_sale_aplicoop/tests/test_product_discovery.py new file mode 100644 index 0000000..7c3a488 --- /dev/null +++ b/website_sale_aplicoop/tests/test_product_discovery.py @@ -0,0 +1,869 @@ +# Copyright 2025 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +""" +Test suite for product discovery logic in website_sale_aplicoop. + +Discovery is owned by `group.order._get_products_for_group_order(order_id)` and +returns the UNION of three inclusion sources: + +1. `product_ids`: directly linked products +2. `category_ids`: products in those categories *and all their subcategories* +3. `supplier_ids`: products offered by those suppliers (via `seller_ids`) + +filtered by `active` / `is_published` / `sale_ok`, and then reduced by three +blacklists that have absolute priority over every inclusion source: + +- `excluded_product_ids` +- `excluded_supplier_ids` (matches `product_tmpl_id.main_seller_id`) +- `excluded_category_ids` (recursive, includes subcategories) + +Note: `product_ids` is an inclusion *input*, never the discovery result. Every +assertion here goes through `_get_products_for_group_order`. +""" + +from datetime import datetime +from datetime import timedelta + +from odoo.exceptions import UserError +from odoo.tests.common import TransactionCase + + +class ProductDiscoveryCommon: + """Shared builders for the discovery test cases.""" + + def _create_group_order(self, name="Test Order", **overrides): + start_date = datetime.now().date() + vals = { + "name": name, + "group_ids": [(6, 0, [self.group.id])], + "type": "regular", + "start_date": start_date, + "end_date": start_date + timedelta(days=7), + "period": "weekly", + "pickup_day": "3", + "cutoff_day": "0", + } + vals.update(overrides) + return self.env["group.order"].create(vals) + + def _create_product(self, name, **overrides): + """Create a sellable, published consu product. + + `consu` products are non-storable by default, so they are never dropped + by the forecasted-stock filter in `_apply_stock_filter_and_sort`. + """ + vals = { + "name": name, + "type": "consu", + "list_price": 10.0, + "is_published": True, + "sale_ok": True, + } + vals.update(overrides) + return self.env["product.product"].create(vals) + + def _create_supplier(self, name): + return self.env["res.partner"].create( + { + "name": name, + "is_company": True, + "supplier_rank": 1, + } + ) + + def _set_main_seller(self, product, supplier, price=1.0): + """Attach a supplierinfo so `main_seller_id` computes to `supplier`. + + `product_main_seller.main_seller_id` is a stored *computed* field that + reads the first entry of `variant_seller_ids`; writing it directly on + create is silently discarded by the compute. + """ + self.env["product.supplierinfo"].create( + { + "partner_id": supplier.id, + "product_tmpl_id": product.product_tmpl_id.id, + "price": price, + } + ) + return product + + def _discover(self, group_order=None): + group_order = group_order or self.group_order + return group_order._get_products_for_group_order(group_order.id) + + +class TestProductDiscoveryUnion(ProductDiscoveryCommon, TransactionCase): + """Discovery returns the union of the three inclusion sources.""" + + def setUp(self): + super().setUp() + self.group = self.env["res.partner"].create( + { + "name": "Test Group", + "is_company": True, + } + ) + + self.supplier = self._create_supplier("Test Supplier") + + self.category1 = self.env["product.category"].create({"name": "Category 1"}) + self.category2 = self.env["product.category"].create({"name": "Category 2"}) + + self.direct_product = self._create_product("Direct Product") + self.cat1_product = self._create_product( + "Category 1 Product", categ_id=self.category1.id, list_price=20.0 + ) + self.cat2_product = self._create_product( + "Category 2 Product", categ_id=self.category2.id, list_price=30.0 + ) + # Reachable both through category1 and through the supplier. + self.supplier_product = self._create_product( + "Supplier Product", categ_id=self.category1.id, list_price=40.0 + ) + self._set_main_seller(self.supplier_product, self.supplier) + + self.group_order = self._create_group_order() + + def test_discovery_from_direct_products(self): + """Directly linked products are discovered.""" + self.group_order.product_ids = [(4, self.direct_product.id)] + + self.assertIn(self.direct_product, self._discover()) + + def test_discovery_from_categories(self): + """Products of a linked category are discovered.""" + self.group_order.category_ids = [(4, self.category1.id)] + + discovered = self._discover() + + self.assertIn(self.cat1_product, discovered) + self.assertIn(self.supplier_product, discovered) + # A product of an unrelated category stays out. + self.assertNotIn(self.cat2_product, discovered) + + def test_discovery_from_suppliers(self): + """Products offered by a linked supplier are discovered.""" + self.group_order.supplier_ids = [(4, self.supplier.id)] + + discovered = self._discover() + + self.assertIn(self.supplier_product, discovered) + self.assertNotIn(self.cat1_product, discovered) + + def test_discovery_union_of_all_sources(self): + """The three sources add up instead of shadowing each other.""" + self.group_order.product_ids = [(4, self.direct_product.id)] + self.group_order.category_ids = [(4, self.category2.id)] + self.group_order.supplier_ids = [(4, self.supplier.id)] + + discovered = self._discover() + + self.assertIn(self.direct_product, discovered) + self.assertIn(self.cat2_product, discovered) + self.assertIn(self.supplier_product, discovered) + + def test_discovery_union_no_duplicates(self): + """A product reachable through all three sources appears once.""" + self.group_order.product_ids = [(4, self.supplier_product.id)] + self.group_order.category_ids = [(4, self.category1.id)] + self.group_order.supplier_ids = [(4, self.supplier.id)] + + discovered = self._discover() + + count = sum(1 for product in discovered if product == self.supplier_product) + self.assertEqual(count, 1) + + def test_discovery_filters_unpublished(self): + """Unpublished products are excluded.""" + unpublished = self._create_product( + "Unpublished Product", + categ_id=self.category1.id, + is_published=False, + ) + + self.group_order.category_ids = [(4, self.category1.id)] + + self.assertNotIn(unpublished, self._discover()) + + def test_discovery_filters_not_for_sale(self): + """Products flagged as not sellable are excluded.""" + not_for_sale = self._create_product( + "Not For Sale", + categ_id=self.category1.id, + sale_ok=False, + ) + + self.group_order.category_ids = [(4, self.category1.id)] + + self.assertNotIn(not_for_sale, self._discover()) + + def test_discovery_filters_archived(self): + """Archived products are excluded even when linked directly.""" + archived = self._create_product("Archived Product") + self.group_order.product_ids = [(4, archived.id)] + archived.active = False + + self.assertNotIn(archived, self._discover()) + + def test_discovery_without_any_source_is_empty(self): + """An order with no inclusion source discovers nothing.""" + self.assertEqual(len(self._discover()), 0) + + +class TestDeepCategoryHierarchies(ProductDiscoveryCommon, TransactionCase): + """Category inclusion walks the whole subtree, never upwards.""" + + def setUp(self): + super().setUp() + self.group = self.env["res.partner"].create( + { + "name": "Test Group", + "is_company": True, + } + ) + + # Level 1 -> Level 2 -> Level 3 -> Level 4 -> Level 5 + self.cat_l1 = self.env["product.category"].create({"name": "Level 1"}) + self.cat_l2 = self.env["product.category"].create( + {"name": "Level 2", "parent_id": self.cat_l1.id} + ) + self.cat_l3 = self.env["product.category"].create( + {"name": "Level 3", "parent_id": self.cat_l2.id} + ) + self.cat_l4 = self.env["product.category"].create( + {"name": "Level 4", "parent_id": self.cat_l3.id} + ) + self.cat_l5 = self.env["product.category"].create( + {"name": "Level 5", "parent_id": self.cat_l4.id} + ) + + self.product_l2 = self._create_product("Product L2", categ_id=self.cat_l2.id) + self.product_l4 = self._create_product("Product L4", categ_id=self.cat_l4.id) + self.product_l5 = self._create_product("Product L5", categ_id=self.cat_l5.id) + + self.group_order = self._create_group_order() + + def test_discovery_root_category_includes_all_descendants(self): + """Linking the root category discovers every nested product.""" + self.group_order.category_ids = [(4, self.cat_l1.id)] + + discovered = self._discover() + + self.assertIn(self.product_l2, discovered) + self.assertIn(self.product_l4, discovered) + self.assertIn(self.product_l5, discovered) + + def test_discovery_mid_level_category_includes_descendants(self): + """Linking a mid-level category discovers its subtree only.""" + self.group_order.category_ids = [(4, self.cat_l3.id)] + + discovered = self._discover() + + self.assertIn(self.product_l4, discovered) + self.assertIn(self.product_l5, discovered) + # L2 is an ancestor of L3, not a descendant. + self.assertNotIn(self.product_l2, discovered) + + def test_discovery_leaf_category_only_own_products(self): + """Linking a leaf category discovers only its own products.""" + self.group_order.category_ids = [(4, self.cat_l5.id)] + + discovered = self._discover() + + self.assertIn(self.product_l5, discovered) + self.assertNotIn(self.product_l4, discovered) + self.assertNotIn(self.product_l2, discovered) + + def test_circular_category_reference_is_rejected(self): + """Odoo refuses a category loop, so discovery cannot recurse forever. + + `_get_products_for_group_order` walks `child_id` recursively with no + depth guard, so it relies on the ORM rejecting cycles up front. + """ + with self.assertRaises(UserError): + self.cat_l1.parent_id = self.cat_l5.id + + +class TestEmptySourcesDiscovery(ProductDiscoveryCommon, TransactionCase): + """Sources that resolve to nothing yield an empty recordset.""" + + def setUp(self): + super().setUp() + self.group = self.env["res.partner"].create( + { + "name": "Test Group", + "is_company": True, + } + ) + + # A category and a supplier, both without any product attached. + self.category = self.env["product.category"].create({"name": "Empty Category"}) + self.supplier = self._create_supplier("Supplier No Products") + + self.group_order = self._create_group_order() + + def test_discovery_empty_category(self): + """A category with no products discovers nothing.""" + self.group_order.category_ids = [(4, self.category.id)] + + self.assertEqual(len(self._discover()), 0) + + def test_discovery_empty_supplier(self): + """A supplier with no products discovers nothing.""" + self.group_order.supplier_ids = [(4, self.supplier.id)] + + self.assertEqual(len(self._discover()), 0) + + def test_discovery_all_sources_empty(self): + """All three sources empty discovers nothing.""" + self.group_order.product_ids = [(6, 0, [])] + self.group_order.category_ids = [(4, self.category.id)] + self.group_order.supplier_ids = [(4, self.supplier.id)] + + self.assertEqual(len(self._discover()), 0) + + def test_discovery_on_missing_order_is_empty(self): + """An unknown order id resolves to an empty recordset, not a crash.""" + missing_id = self.group_order.id + self.group_order.unlink() + + products = self.env["group.order"]._get_products_for_group_order(missing_id) + + self.assertEqual(len(products), 0) + + +class TestProductDiscoveryOrdering(ProductDiscoveryCommon, TransactionCase): + """Discovery sorts by (out of stock, website_sequence, lowercased name).""" + + def setUp(self): + super().setUp() + self.group = self.env["res.partner"].create( + { + "name": "Test Group", + "is_company": True, + } + ) + + self.category = self.env["product.category"].create({"name": "Test Category"}) + + # Same website_sequence everywhere so the name is the tie-breaker. + # Created out of alphabetical order on purpose. + self.products = self.env["product.product"] + for name in ("Product D", "Product A", "Product E", "Product C", "Product B"): + product = self._create_product(name, categ_id=self.category.id) + product.product_tmpl_id.website_sequence = 10 + self.products |= product + + self.group_order = self._create_group_order() + self.group_order.category_ids = [(4, self.category.id)] + + def test_discovery_consistent_ordering(self): + """Repeated calls return the same order.""" + first = self._discover().ids + second = self._discover().ids + + self.assertEqual(first, second) + + def test_discovery_sorted_by_name_within_same_sequence(self): + """With an equal website_sequence, products come out name-sorted.""" + discovered = self._discover() + + self.assertEqual( + discovered.mapped("name"), + ["Product A", "Product B", "Product C", "Product D", "Product E"], + ) + + def test_discovery_respects_website_sequence(self): + """website_sequence wins over the alphabetical tie-breaker.""" + last_alphabetically = self.products.filtered(lambda p: p.name == "Product E") + last_alphabetically.product_tmpl_id.website_sequence = 1 + + discovered = self._discover() + + self.assertEqual(discovered[0], last_alphabetically) + + def test_discovery_returns_every_category_product(self): + """No product of the linked category is dropped along the way.""" + discovered = self._discover() + + self.assertEqual(set(discovered.ids), set(self.products.ids)) + + +class TestProductBlacklist(ProductDiscoveryCommon, TransactionCase): + """Test blacklist (excluded_product_ids) functionality. + + The blacklist must have absolute priority over all inclusion sources: + - Direct product_ids + - Products from category_ids + - Products from supplier_ids + + If a product is in excluded_product_ids, it should NEVER appear in + the discovered products, regardless of how it was included. + """ + + def setUp(self): + super().setUp() + self.group = self.env["res.partner"].create( + { + "name": "Test Group", + "is_company": True, + } + ) + + self.supplier = self._create_supplier("Test Supplier") + self.category = self.env["product.category"].create({"name": "Test Category"}) + + # 1. Direct product (will be added to product_ids) + self.direct_product = self._create_product("Direct Product") + + # 2. Category product (will be included via category_ids) + self.category_product = self._create_product( + "Category Product", categ_id=self.category.id, list_price=20.0 + ) + + # 3. Supplier product (will be included via supplier_ids) + self.supplier_product = self._create_product( + "Supplier Product", list_price=30.0 + ) + self._set_main_seller(self.supplier_product, self.supplier, price=25.0) + + # 4. Multi-source product (reachable from all three sources) + self.multi_product = self._create_product( + "Multi Source Product", categ_id=self.category.id, list_price=40.0 + ) + self._set_main_seller(self.multi_product, self.supplier, price=35.0) + + self.group_order = self._create_group_order("Test Blacklist Order") + + def test_blacklist_excludes_direct_product(self): + """Test that excluded_product_ids filters out directly linked products.""" + self.group_order.product_ids = [(4, self.direct_product.id)] + self.assertIn(self.direct_product, self._discover()) + + self.group_order.excluded_product_ids = [(4, self.direct_product.id)] + + self.assertNotIn(self.direct_product, self._discover()) + + def test_blacklist_excludes_category_product(self): + """Test that excluded_product_ids filters out products from categories.""" + self.group_order.category_ids = [(4, self.category.id)] + self.assertIn(self.category_product, self._discover()) + + self.group_order.excluded_product_ids = [(4, self.category_product.id)] + + self.assertNotIn(self.category_product, self._discover()) + + def test_blacklist_excludes_supplier_product(self): + """Test that excluded_product_ids filters out products from suppliers.""" + self.group_order.supplier_ids = [(4, self.supplier.id)] + self.assertIn(self.supplier_product, self._discover()) + + self.group_order.excluded_product_ids = [(4, self.supplier_product.id)] + + self.assertNotIn(self.supplier_product, self._discover()) + + def test_blacklist_priority_over_all_sources(self): + """Test that blacklist has absolute priority for multi-source products.""" + self.group_order.product_ids = [(4, self.multi_product.id)] + self.group_order.category_ids = [(4, self.category.id)] + self.group_order.supplier_ids = [(4, self.supplier.id)] + self.assertIn(self.multi_product, self._discover()) + + self.group_order.excluded_product_ids = [(4, self.multi_product.id)] + + self.assertNotIn(self.multi_product, self._discover()) + + def test_empty_blacklist_no_effect(self): + """Test that empty excluded_product_ids doesn't affect discovery.""" + self.group_order.product_ids = [(4, self.direct_product.id)] + self.group_order.category_ids = [(4, self.category.id)] + self.group_order.supplier_ids = [(4, self.supplier.id)] + + discovered = self._discover() + + self.assertEqual(len(self.group_order.excluded_product_ids), 0) + self.assertIn(self.direct_product, discovered) + self.assertIn(self.category_product, discovered) + self.assertIn(self.supplier_product, discovered) + + def test_blacklist_multiple_products(self): + """Test excluding multiple products at once.""" + self.group_order.product_ids = [(4, self.direct_product.id)] + self.group_order.category_ids = [(4, self.category.id)] + self.group_order.supplier_ids = [(4, self.supplier.id)] + + self.group_order.excluded_product_ids = [ + (4, self.direct_product.id), + (4, self.category_product.id), + ] + + discovered = self._discover() + + self.assertNotIn(self.direct_product, discovered) + self.assertNotIn(self.category_product, discovered) + self.assertIn(self.supplier_product, discovered) + + def test_blacklist_available_products_count(self): + """Test that available_products_count reflects blacklist.""" + self.group_order.product_ids = [(4, self.direct_product.id)] + self.group_order.category_ids = [(4, self.category.id)] + + initial_count = self.group_order.available_products_count + self.assertGreater(initial_count, 0) + + self.group_order.excluded_product_ids = [(4, self.category_product.id)] + + self.assertEqual(self.group_order.available_products_count, initial_count - 1) + + +class TestSupplierBlacklist(ProductDiscoveryCommon, TransactionCase): + """Test supplier blacklist (excluded_supplier_ids) functionality. + + The supplier blacklist filters out products whose main_seller_id + (from product_main_seller addon) is in the excluded suppliers list. + + Blacklist has absolute priority over inclusion sources. + """ + + def setUp(self): + super().setUp() + self.group = self.env["res.partner"].create( + { + "name": "Test Group", + "is_company": True, + } + ) + + self.supplier_A = self._create_supplier("Supplier A") + self.supplier_B = self._create_supplier("Supplier B") + self.supplier_C = self._create_supplier("Supplier C") + + self.category = self.env["product.category"].create({"name": "Test Category"}) + + # main_seller_id is computed from the supplierinfo entries, so each + # product gets its main vendor through _set_main_seller. + self.product_A = self._create_product( + "Product from Supplier A", categ_id=self.category.id + ) + self._set_main_seller(self.product_A, self.supplier_A) + + self.product_B = self._create_product( + "Product from Supplier B", categ_id=self.category.id, list_price=20.0 + ) + self._set_main_seller(self.product_B, self.supplier_B) + + self.product_C = self._create_product( + "Product from Supplier C", categ_id=self.category.id, list_price=30.0 + ) + self._set_main_seller(self.product_C, self.supplier_C) + + self.product_no_seller = self._create_product( + "Product without main seller", categ_id=self.category.id, list_price=40.0 + ) + + self.group_order = self._create_group_order("Test Supplier Blacklist Order") + + def test_main_seller_is_computed_from_supplierinfo(self): + """Guard: the fixtures really do carry a main vendor.""" + self.assertEqual(self.product_A.product_tmpl_id.main_seller_id, self.supplier_A) + self.assertEqual(self.product_B.product_tmpl_id.main_seller_id, self.supplier_B) + self.assertFalse(self.product_no_seller.product_tmpl_id.main_seller_id) + + def test_supplier_blacklist_excludes_by_main_seller(self): + """Test that supplier blacklist excludes products by main_seller_id.""" + self.group_order.category_ids = [(4, self.category.id)] + + discovered = self._discover() + self.assertIn(self.product_A, discovered) + self.assertIn(self.product_B, discovered) + self.assertIn(self.product_C, discovered) + self.assertIn(self.product_no_seller, discovered) + + self.group_order.excluded_supplier_ids = [(4, self.supplier_A.id)] + + discovered = self._discover() + self.assertNotIn(self.product_A, discovered) + self.assertIn(self.product_B, discovered) + self.assertIn(self.product_C, discovered) + self.assertIn(self.product_no_seller, discovered) + + def test_supplier_blacklist_multiple_suppliers(self): + """Test excluding multiple suppliers at once.""" + self.group_order.category_ids = [(4, self.category.id)] + + self.group_order.excluded_supplier_ids = [ + (4, self.supplier_A.id), + (4, self.supplier_B.id), + ] + + discovered = self._discover() + + self.assertNotIn(self.product_A, discovered) + self.assertNotIn(self.product_B, discovered) + self.assertIn(self.product_C, discovered) + self.assertIn(self.product_no_seller, discovered) + + def test_supplier_blacklist_does_not_affect_no_main_seller(self): + """Products without a main vendor survive a supplier blacklist.""" + self.group_order.category_ids = [(4, self.category.id)] + + self.group_order.excluded_supplier_ids = [ + (4, self.supplier_A.id), + (4, self.supplier_B.id), + (4, self.supplier_C.id), + ] + + discovered = self._discover() + + self.assertNotIn(self.product_A, discovered) + self.assertNotIn(self.product_B, discovered) + self.assertNotIn(self.product_C, discovered) + self.assertIn(self.product_no_seller, discovered) + + def test_supplier_blacklist_with_direct_product_inclusion(self): + """Test that supplier blacklist affects even directly included products.""" + self.group_order.product_ids = [(4, self.product_A.id)] + self.assertIn(self.product_A, self._discover()) + + self.group_order.excluded_supplier_ids = [(4, self.supplier_A.id)] + + self.assertNotIn(self.product_A, self._discover()) + + def test_supplier_blacklist_with_supplier_inclusion(self): + """Test that supplier blacklist has priority over supplier inclusion.""" + self.group_order.supplier_ids = [(4, self.supplier_A.id)] + self.assertIn(self.product_A, self._discover()) + + self.group_order.excluded_supplier_ids = [(4, self.supplier_A.id)] + + self.assertNotIn(self.product_A, self._discover()) + + def test_empty_supplier_blacklist_no_effect(self): + """Test that empty excluded_supplier_ids doesn't affect discovery.""" + self.group_order.category_ids = [(4, self.category.id)] + + discovered = self._discover() + + self.assertEqual(len(self.group_order.excluded_supplier_ids), 0) + self.assertIn(self.product_A, discovered) + self.assertIn(self.product_B, discovered) + self.assertIn(self.product_C, discovered) + + def test_supplier_and_product_blacklist_combined(self): + """Test that both product and supplier blacklists work together.""" + self.group_order.category_ids = [(4, self.category.id)] + + self.group_order.excluded_supplier_ids = [(4, self.supplier_A.id)] + self.group_order.excluded_product_ids = [(4, self.product_B.id)] + + discovered = self._discover() + + self.assertNotIn(self.product_A, discovered) + self.assertNotIn(self.product_B, discovered) + self.assertIn(self.product_C, discovered) + self.assertIn(self.product_no_seller, discovered) + + def test_supplier_blacklist_available_products_count(self): + """Test that available_products_count reflects supplier blacklist.""" + self.group_order.category_ids = [(4, self.category.id)] + + self.assertEqual(self.group_order.available_products_count, 4) + + self.group_order.excluded_supplier_ids = [(4, self.supplier_A.id)] + + self.assertEqual(self.group_order.available_products_count, 3) + + +class TestCategoryBlacklist(ProductDiscoveryCommon, TransactionCase): + """Test category blacklist (excluded_category_ids) functionality. + + The category blacklist filters out products in the excluded categories + AND all their subcategories (recursive). + + Blacklist has absolute priority over inclusion sources. + """ + + def setUp(self): + super().setUp() + self.group = self.env["res.partner"].create( + { + "name": "Test Group", + "is_company": True, + } + ) + + # Parent Category + # ├── Child Category A + # │ └── Grandchild Category A1 + # └── Child Category B + self.parent_category = self.env["product.category"].create( + {"name": "Parent Category"} + ) + self.child_category_A = self.env["product.category"].create( + {"name": "Child Category A", "parent_id": self.parent_category.id} + ) + self.grandchild_category_A1 = self.env["product.category"].create( + { + "name": "Grandchild Category A1", + "parent_id": self.child_category_A.id, + } + ) + self.child_category_B = self.env["product.category"].create( + {"name": "Child Category B", "parent_id": self.parent_category.id} + ) + self.other_category = self.env["product.category"].create( + {"name": "Other Category (not in hierarchy)"} + ) + + self.product_parent = self._create_product( + "Product in Parent Category", categ_id=self.parent_category.id + ) + self.product_child_A = self._create_product( + "Product in Child A", categ_id=self.child_category_A.id, list_price=20.0 + ) + self.product_grandchild_A1 = self._create_product( + "Product in Grandchild A1", + categ_id=self.grandchild_category_A1.id, + list_price=30.0, + ) + self.product_child_B = self._create_product( + "Product in Child B", categ_id=self.child_category_B.id, list_price=40.0 + ) + self.product_other = self._create_product( + "Product in Other Category", + categ_id=self.other_category.id, + list_price=50.0, + ) + + self.group_order = self._create_group_order("Test Category Blacklist Order") + + def test_category_blacklist_excludes_single_category(self): + """Test that category blacklist excludes products in that category.""" + self.group_order.category_ids = [(4, self.parent_category.id)] + + discovered = self._discover() + self.assertIn(self.product_parent, discovered) + self.assertIn(self.product_child_A, discovered) + + self.group_order.excluded_category_ids = [(4, self.child_category_B.id)] + + discovered = self._discover() + self.assertNotIn(self.product_child_B, discovered) + self.assertIn(self.product_parent, discovered) + self.assertIn(self.product_child_A, discovered) + + def test_category_blacklist_excludes_with_subcategories(self): + """Excluding a category also excludes its subcategories.""" + self.group_order.category_ids = [(4, self.parent_category.id)] + + self.group_order.excluded_category_ids = [(4, self.child_category_A.id)] + + discovered = self._discover() + + self.assertNotIn(self.product_child_A, discovered) + self.assertNotIn(self.product_grandchild_A1, discovered) + self.assertIn(self.product_parent, discovered) + self.assertIn(self.product_child_B, discovered) + + def test_category_blacklist_excludes_parent_excludes_all_children(self): + """Excluding the root of the hierarchy empties the whole subtree.""" + self.group_order.category_ids = [(4, self.parent_category.id)] + + self.group_order.excluded_category_ids = [(4, self.parent_category.id)] + + discovered = self._discover() + + self.assertNotIn(self.product_parent, discovered) + self.assertNotIn(self.product_child_A, discovered) + self.assertNotIn(self.product_grandchild_A1, discovered) + self.assertNotIn(self.product_child_B, discovered) + + def test_category_blacklist_with_direct_product_inclusion(self): + """The category blacklist also beats a direct product inclusion.""" + self.group_order.product_ids = [(4, self.product_child_A.id)] + self.assertIn(self.product_child_A, self._discover()) + + self.group_order.excluded_category_ids = [(4, self.child_category_A.id)] + + self.assertNotIn(self.product_child_A, self._discover()) + + def test_category_blacklist_does_not_affect_other_categories(self): + """Products outside the excluded subtree are untouched.""" + self.group_order.category_ids = [ + (4, self.parent_category.id), + (4, self.other_category.id), + ] + + self.group_order.excluded_category_ids = [(4, self.parent_category.id)] + + self.assertIn(self.product_other, self._discover()) + + def test_empty_category_blacklist_no_effect(self): + """Test that empty excluded_category_ids doesn't affect discovery.""" + self.group_order.category_ids = [(4, self.parent_category.id)] + + discovered = self._discover() + + self.assertEqual(len(self.group_order.excluded_category_ids), 0) + self.assertIn(self.product_parent, discovered) + self.assertIn(self.product_child_A, discovered) + self.assertIn(self.product_grandchild_A1, discovered) + self.assertIn(self.product_child_B, discovered) + + def test_multiple_category_exclusions(self): + """Test excluding several categories at once.""" + self.group_order.category_ids = [ + (4, self.parent_category.id), + (4, self.other_category.id), + ] + + self.group_order.excluded_category_ids = [ + (4, self.child_category_B.id), + (4, self.other_category.id), + ] + + discovered = self._discover() + + self.assertNotIn(self.product_child_B, discovered) + self.assertNotIn(self.product_other, discovered) + self.assertIn(self.product_parent, discovered) + self.assertIn(self.product_child_A, discovered) + + def test_category_blacklist_combined_with_other_blacklists(self): + """Category, product and supplier blacklists stack.""" + supplier = self._create_supplier("Blacklisted Supplier") + supplier_product = self._create_product( + "Product from blacklisted supplier", categ_id=self.other_category.id + ) + self._set_main_seller(supplier_product, supplier) + + self.group_order.category_ids = [ + (4, self.parent_category.id), + (4, self.other_category.id), + ] + + self.group_order.excluded_category_ids = [(4, self.child_category_A.id)] + self.group_order.excluded_product_ids = [(4, self.product_child_B.id)] + self.group_order.excluded_supplier_ids = [(4, supplier.id)] + + discovered = self._discover() + + self.assertNotIn(self.product_child_A, discovered) + self.assertNotIn(self.product_grandchild_A1, discovered) + self.assertNotIn(self.product_child_B, discovered) + self.assertNotIn(supplier_product, discovered) + self.assertIn(self.product_parent, discovered) + self.assertIn(self.product_other, discovered) + + def test_category_blacklist_available_products_count(self): + """Test that available_products_count reflects the category blacklist.""" + self.group_order.category_ids = [(4, self.parent_category.id)] + + # parent + child A + grandchild A1 + child B + self.assertEqual(self.group_order.available_products_count, 4) + + # Drops child A and its grandchild. + self.group_order.excluded_category_ids = [(4, self.child_category_A.id)] + + self.assertEqual(self.group_order.available_products_count, 2)