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_autopause_cron
|
||||
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