[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 <noreply@anthropic.com>
This commit is contained in:
parent
3eb79e2431
commit
3e4bd5e5db
6 changed files with 1817 additions and 0 deletions
147
website_sale_aplicoop/COBERTURA_TESTS_PENDIENTE.md
Normal file
147
website_sale_aplicoop/COBERTURA_TESTS_PENDIENTE.md
Normal file
|
|
@ -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/<fichero>.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.
|
||||||
|
|
@ -19,3 +19,7 @@ from . import test_group_order_status_endpoint # noqa: F401
|
||||||
from . import test_home_delivery # noqa: F401
|
from . import test_home_delivery # noqa: F401
|
||||||
from . import test_forecasted_stock # noqa: F401
|
from . import test_forecasted_stock # noqa: F401
|
||||||
from . import test_online_payment # 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
|
||||||
|
|
|
||||||
280
website_sale_aplicoop/tests/test_date_edge_cases.py
Normal file
280
website_sale_aplicoop/tests/test_date_edge_cases.py
Normal file
|
|
@ -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)
|
||||||
207
website_sale_aplicoop/tests/test_portal_routes.py
Normal file
207
website_sale_aplicoop/tests/test_portal_routes.py
Normal file
|
|
@ -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)
|
||||||
310
website_sale_aplicoop/tests/test_price_with_taxes_included.py
Normal file
310
website_sale_aplicoop/tests/test_price_with_taxes_included.py
Normal file
|
|
@ -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)
|
||||||
869
website_sale_aplicoop/tests/test_product_discovery.py
Normal file
869
website_sale_aplicoop/tests/test_product_discovery.py
Normal file
|
|
@ -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)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue