test(jobs): 6 tests for recipe-level cert suppression + orphan gate
Six failing tests in test_recipe_cert_suppression.py covering the
full design surface:
1. test_recipe_suppresses_thickness
2. test_recipe_suppresses_nadcap_for_commodity_part
3. test_recipe_cannot_add_certs_customer_didnt_want (suppress-only
regression guard — recipe can never add types customer didn't ask for)
4. test_part_override_coc_recipe_suppresses
5. test_all_orphan_types_propagate (4-element output + bundling)
6. test_orphan_cert_issue_blocks_without_attachment
These will all fail until T4 (resolver) and T5 (orphan-attach gate)
land. RED phase of TDD locked in via commit ordering.
Sub: docs/superpowers/specs/2026-05-27-recipe-cert-toggles-design.md
Task: T3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,3 +8,4 @@ from . import test_late_risk_ratio
|
|||||||
from . import test_active_step_id
|
from . import test_active_step_id
|
||||||
from . import test_autopause_cron
|
from . import test_autopause_cron
|
||||||
from . import test_post_shop_states
|
from . import test_post_shop_states
|
||||||
|
from . import test_recipe_cert_suppression
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1
|
||||||
|
"""Recipe-level cert suppression tests (spec 2026-05-27).
|
||||||
|
|
||||||
|
Verifies fp.job._resolve_required_cert_types respects the five
|
||||||
|
recipe-level requires_* Booleans (suppress-only precedence) and the
|
||||||
|
orphan-cert action_issue gate raises on missing attachment.
|
||||||
|
|
||||||
|
Spec: docs/superpowers/specs/2026-05-27-recipe-cert-toggles-design.md
|
||||||
|
Plan: docs/superpowers/plans/2026-05-27-recipe-cert-toggles-plan.md
|
||||||
|
"""
|
||||||
|
from odoo.tests.common import TransactionCase
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecipeCertSuppression(TransactionCase):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
cls.partner = cls.env['res.partner'].create({
|
||||||
|
'name': 'AeroCustomer',
|
||||||
|
'is_company': True,
|
||||||
|
})
|
||||||
|
cls.product = cls.env['product.product'].create({'name': 'AeroPart'})
|
||||||
|
# Recipe = top-level fusion.plating.process.node with node_type='recipe'
|
||||||
|
cls.recipe = cls.env['fusion.plating.process.node'].create({
|
||||||
|
'name': 'TestRecipe',
|
||||||
|
'node_type': 'recipe',
|
||||||
|
})
|
||||||
|
|
||||||
|
_part_seq = 0
|
||||||
|
|
||||||
|
def _make_part(self, **kw):
|
||||||
|
# Bump per-test counter so multiple parts in the same test don't
|
||||||
|
# collide on the (partner_id, part_number) uniqueness constraint.
|
||||||
|
type(self)._part_seq += 1
|
||||||
|
vals = {
|
||||||
|
'name': 'PartA',
|
||||||
|
'part_number': 'PN-CERT-%03d' % self._part_seq,
|
||||||
|
'partner_id': self.partner.id,
|
||||||
|
'certificate_requirement': 'inherit',
|
||||||
|
}
|
||||||
|
vals.update(kw)
|
||||||
|
return self.env['fp.part.catalog'].create(vals)
|
||||||
|
|
||||||
|
def _make_job(self, **kw):
|
||||||
|
vals = {
|
||||||
|
'partner_id': self.partner.id,
|
||||||
|
'product_id': self.product.id,
|
||||||
|
'qty': 1.0,
|
||||||
|
'recipe_id': self.recipe.id,
|
||||||
|
}
|
||||||
|
vals.update(kw)
|
||||||
|
return self.env['fp.job'].create(vals)
|
||||||
|
|
||||||
|
# ---- Test 1: recipe suppresses thickness ----
|
||||||
|
def test_recipe_suppresses_thickness(self):
|
||||||
|
"""Customer wants thickness, recipe says no thickness -> only CoC."""
|
||||||
|
self.partner.x_fc_send_coc = True
|
||||||
|
self.partner.x_fc_send_thickness_report = True
|
||||||
|
self.recipe.requires_thickness_report = False
|
||||||
|
part = self._make_part()
|
||||||
|
job = self._make_job(part_catalog_id=part.id)
|
||||||
|
result = job._resolve_required_cert_types()
|
||||||
|
# CoC stays, thickness is suppressed.
|
||||||
|
self.assertEqual(result, {'coc'})
|
||||||
|
|
||||||
|
# ---- Test 2: recipe suppresses nadcap on commodity part ----
|
||||||
|
def test_recipe_suppresses_nadcap_for_commodity_part(self):
|
||||||
|
"""Customer wants nadcap, commodity recipe says no nadcap -> none."""
|
||||||
|
self.partner.x_fc_send_coc = True
|
||||||
|
self.partner.x_fc_send_nadcap_cert = True
|
||||||
|
self.recipe.requires_nadcap_cert = False
|
||||||
|
part = self._make_part()
|
||||||
|
job = self._make_job(part_catalog_id=part.id)
|
||||||
|
result = job._resolve_required_cert_types()
|
||||||
|
self.assertEqual(result, {'coc'})
|
||||||
|
self.assertNotIn('nadcap_cert', result)
|
||||||
|
|
||||||
|
# ---- Test 3: recipe cannot ADD what customer didn't want ----
|
||||||
|
def test_recipe_cannot_add_certs_customer_didnt_want(self):
|
||||||
|
"""Partner all OFF, recipe all True -> empty (suppress-only)."""
|
||||||
|
self.partner.x_fc_send_coc = False
|
||||||
|
self.partner.x_fc_send_thickness_report = False
|
||||||
|
self.partner.x_fc_send_nadcap_cert = False
|
||||||
|
self.partner.x_fc_send_mill_test = False
|
||||||
|
self.partner.x_fc_send_customer_specific = False
|
||||||
|
# All recipe requires_* default True; do not flip anything.
|
||||||
|
part = self._make_part()
|
||||||
|
job = self._make_job(part_catalog_id=part.id)
|
||||||
|
self.assertEqual(job._resolve_required_cert_types(), set())
|
||||||
|
|
||||||
|
# ---- Test 4: recipe can suppress part-level override ----
|
||||||
|
def test_part_override_coc_recipe_suppresses(self):
|
||||||
|
"""Part says coc, recipe says no coc -> empty (recipe wins)."""
|
||||||
|
self.recipe.requires_coc = False
|
||||||
|
part = self._make_part(certificate_requirement='coc')
|
||||||
|
job = self._make_job(part_catalog_id=part.id)
|
||||||
|
self.assertEqual(job._resolve_required_cert_types(), set())
|
||||||
|
|
||||||
|
# ---- Test 5: all 3 orphan types propagate when customer wants them ----
|
||||||
|
def test_all_orphan_types_propagate(self):
|
||||||
|
"""All 5 partner toggles ON, recipe default -> 4-element set
|
||||||
|
(thickness collapses into CoC via bundling rule)."""
|
||||||
|
self.partner.x_fc_send_coc = True
|
||||||
|
self.partner.x_fc_send_thickness_report = True
|
||||||
|
self.partner.x_fc_send_nadcap_cert = True
|
||||||
|
self.partner.x_fc_send_mill_test = True
|
||||||
|
self.partner.x_fc_send_customer_specific = True
|
||||||
|
part = self._make_part()
|
||||||
|
job = self._make_job(part_catalog_id=part.id)
|
||||||
|
result = job._resolve_required_cert_types()
|
||||||
|
# thickness merges into CoC PDF (bundling rule preserved)
|
||||||
|
self.assertEqual(
|
||||||
|
result,
|
||||||
|
{'coc', 'nadcap_cert', 'mill_test', 'customer_specific'},
|
||||||
|
)
|
||||||
|
self.assertNotIn('thickness_report', result)
|
||||||
|
|
||||||
|
# ---- Test 6: orphan cert blocks Issue without attachment ----
|
||||||
|
def test_orphan_cert_issue_blocks_without_attachment(self):
|
||||||
|
"""Spawn a Nadcap cert with no attachment -> action_issue raises."""
|
||||||
|
# Give the partner an email so the existing email-on-contact gate
|
||||||
|
# doesn't fire first; we want to verify the NEW gate explicitly.
|
||||||
|
self.partner.email = 'qa@aerocustomer.test'
|
||||||
|
cert = self.env['fp.certificate'].create({
|
||||||
|
'name': 'TEST-NADCAP-001',
|
||||||
|
'certificate_type': 'nadcap_cert',
|
||||||
|
'state': 'draft',
|
||||||
|
'partner_id': self.partner.id,
|
||||||
|
'contact_partner_id': self.partner.id,
|
||||||
|
'spec_reference': 'AMS 2404',
|
||||||
|
'process_description': 'TEST PROCESS',
|
||||||
|
'certified_by_id': self.env.user.id,
|
||||||
|
})
|
||||||
|
# Bypass the QM-only authority gate so the failure we see is the
|
||||||
|
# NEW gate, not the pre-existing authority check.
|
||||||
|
with self.assertRaisesRegex(UserError, 'no PDF attached'):
|
||||||
|
cert.with_context(
|
||||||
|
fp_skip_cert_authority_gate=True
|
||||||
|
).action_issue()
|
||||||
Reference in New Issue
Block a user