diff --git a/fusion_plating/fusion_plating_jobs/tests/__init__.py b/fusion_plating/fusion_plating_jobs/tests/__init__.py index 8660166e..b1987d68 100644 --- a/fusion_plating/fusion_plating_jobs/tests/__init__.py +++ b/fusion_plating/fusion_plating_jobs/tests/__init__.py @@ -8,3 +8,4 @@ from . import test_late_risk_ratio from . import test_active_step_id from . import test_autopause_cron from . import test_post_shop_states +from . import test_recipe_cert_suppression diff --git a/fusion_plating/fusion_plating_jobs/tests/test_recipe_cert_suppression.py b/fusion_plating/fusion_plating_jobs/tests/test_recipe_cert_suppression.py new file mode 100644 index 00000000..6e851001 --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/tests/test_recipe_cert_suppression.py @@ -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()