ixg_platform/apps/compliance/tests.py
ixgadmin 08096e168f P99C: compliance automation - CoA auto-generation and EUDR due diligence
- Versioned product specification table (Unrefined Shea Butter v1.0 seed) with
  spec-based QC evaluation replacing hard-coded thresholds
- Structured QC results with test methods and four-eyes verification
  (submitter cannot self-approve)
- reportlab IXG Quality Summary PDF auto-generated on quality approval,
  SHA-256 hashed; original independent lab PDF kept authoritative
- CertificateOfAnalysis version/status/hash/approval/supersede lifecycle;
  commercial release publishes to buyers and triggers notification
- EUDR evidence workflow: Facility, OriginZone, CollectorGroup, HarvestIntake,
  IntakeBatchLineage, DueDiligenceCase, EUDREvidence, RiskAssessment,
  MitigationAction, DueDiligenceStatementReference with rule-based
  completeness check and risk assessment (no AI legal conclusions)
- Ops portal: QC Approvals, CoA Reviews, EUDR Compliance screens
- Buyer portal: /portal/compliance page
- 22 automated tests covering four-eyes, CAPA, PDF+hash, publish/notify,
  EUDR blocks and buyer isolation
2026-08-05 13:58:39 +00:00

305 lines
14 KiB
Python

import os
import tempfile
from decimal import Decimal
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from rest_framework.test import APIClient
from apps.batches.models import ProcessingBatch
from apps.capa.models import CAPARecord
from apps.documents.models import CertificateOfAnalysis, Document
from apps.notifications.models import Notification
from apps.qc.models import QCResult
from .models import (
CollectorGroup, DueDiligenceCase, Facility, HarvestIntake, IntakeBatchLineage,
OriginZone, ProductSpecification, SpecParameter,
)
from . import services
User = get_user_model()
def make_user(email, role):
# Mirrors the Keycloak OIDC backend, which creates all users as is_staff=True.
return User.objects.create_user(
email=email, password="pass1234", role=role,
full_name=email.split("@")[0], is_staff=True,
)
def make_batch(user):
return ProcessingBatch.objects.create(
batch_reference=f"IXG-BATCH-TEST-{ProcessingBatch.objects.count() + 1}",
grade="A",
weight_kg=Decimal("1000.00"),
origin_facility="ASI - Katsina State, Nigeria",
created_by=user,
)
class SpecEvaluationTests(TestCase):
def setUp(self):
self.spec = ProductSpecification.objects.create(
product="Unrefined Shea Butter", version="9.9", status="approved", is_current=True
)
SpecParameter.objects.create(spec=self.spec, parameter="ffa", unit="%", max_value=Decimal("3.00"), sort_order=1)
SpecParameter.objects.create(spec=self.spec, parameter="moisture", unit="%", max_value=Decimal("0.10"), sort_order=2)
SpecParameter.objects.create(spec=self.spec, parameter="peroxide", unit="meq O2/kg", max_value=Decimal("10.00"), sort_order=3)
SpecParameter.objects.create(spec=self.spec, parameter="visual", unit="grade", allowed_values="A,B,C", sort_order=4)
def test_pass_within_spec(self):
values = {"ffa": "2.0", "moisture": "0.05", "peroxide": "5.0", "visual_grade": "A"}
overall_pass, result_status, _, version = services.evaluate_qc_data(values, spec=self.spec)
self.assertTrue(overall_pass)
self.assertEqual(result_status, QCResult.ResultStatus.WITHIN_SPEC)
self.assertEqual(version, "9.9")
def test_fail_outside_spec(self):
values = {"ffa": "5.0", "moisture": "0.05", "peroxide": "5.0", "visual_grade": "A"}
overall_pass, result_status, _, _ = services.evaluate_qc_data(values, spec=self.spec)
self.assertFalse(overall_pass)
self.assertEqual(result_status, QCResult.ResultStatus.OUTSIDE_SPEC)
def test_fail_visual_grade(self):
values = {"ffa": "2.0", "moisture": "0.05", "peroxide": "5.0", "visual_grade": "Fail"}
overall_pass, result_status, _, _ = services.evaluate_qc_data(values, spec=self.spec)
self.assertFalse(overall_pass)
def test_fallback_legacy_limits_when_no_spec(self):
values = {"ffa": "2.0", "moisture": "0.05", "peroxide": "5.0", "visual_grade": "A"}
overall_pass, result_status, _, version = services.evaluate_qc_data(values, spec=None)
self.assertTrue(overall_pass)
self.assertEqual(version, "")
class QCWorkflowTests(TestCase):
def setUp(self):
self.ops_a = make_user("ops.a@ixg.local", "ops")
self.ops_b = make_user("ops.b@ixg.local", "ops")
self.buyer = make_user("buyer@ixg.local", "buyer")
self.batch = make_batch(self.ops_a)
self.client = APIClient()
def create_qc(self, values, user=None):
self.client.force_authenticate(user=user or self.ops_a)
payload = {
"batch": str(self.batch.id),
"ffa_percentage": values[0],
"moisture_percentage": values[1],
"peroxide_value": values[2],
"visual_grade": values[3],
}
return self.client.post("/api/qc-results/", payload, format="json")
def test_qc_fail_creates_capa_and_quarantine(self):
resp = self.create_qc(["9.0", "0.9", "20", "Fail"])
self.assertEqual(resp.status_code, 201)
qc = QCResult.objects.get(batch=self.batch)
self.assertFalse(qc.overall_pass)
self.assertTrue(qc.is_quarantine)
self.assertEqual(qc.entered_by, self.ops_a)
self.assertEqual(qc.result_status, QCResult.ResultStatus.OUTSIDE_SPEC)
self.batch.refresh_from_db()
self.assertEqual(self.batch.status, ProcessingBatch.Status.QC_FAIL)
self.assertTrue(CAPARecord.objects.filter(batch=self.batch).exists())
def test_qc_pass_sets_batch_qc_pass(self):
resp = self.create_qc(["2.0", "0.05", "5.0", "A"])
self.assertEqual(resp.status_code, 201)
qc = QCResult.objects.get(batch=self.batch)
self.assertTrue(qc.overall_pass)
self.batch.refresh_from_db()
self.assertEqual(self.batch.status, ProcessingBatch.Status.QC_PASS)
def test_verify_four_eyes_blocks_submitter(self):
qc = QCResult.objects.create(
batch=self.batch, ffa_percentage=Decimal("2.0"), moisture_percentage=Decimal("0.05"),
peroxide_value=Decimal("5.0"), visual_grade="A", overall_pass=True, entered_by=self.ops_a,
)
self.client.force_authenticate(user=self.ops_a)
resp = self.client.post(f"/api/qc-results/{qc.id}/verify/", {"approved": "true"}, format="json")
self.assertEqual(resp.status_code, 400)
qc.refresh_from_db()
self.assertEqual(qc.verification_status, QCResult.VerificationStatus.DRAFT)
def test_verify_approval_generates_coa(self):
qc = QCResult.objects.create(
batch=self.batch, ffa_percentage=Decimal("2.0"), moisture_percentage=Decimal("0.05"),
peroxide_value=Decimal("5.0"), visual_grade="A", overall_pass=True, entered_by=self.ops_a,
spec_version="1.0",
)
self.client.force_authenticate(user=self.ops_b)
resp = self.client.post(f"/api/qc-results/{qc.id}/verify/", {"approved": "true"}, format="json")
self.assertEqual(resp.status_code, 200)
qc.refresh_from_db()
self.assertEqual(qc.verification_status, QCResult.VerificationStatus.APPROVED)
self.assertEqual(qc.verified_by, self.ops_b)
coa = CertificateOfAnalysis.objects.get(qc_result=qc)
self.assertEqual(coa.status, CertificateOfAnalysis.Status.PENDING_APPROVAL)
self.assertTrue(coa.sha256_hash)
self.assertTrue(os.path.exists(coa.file_url.replace("/media/", "media/")))
@override_settings(MEDIA_ROOT=tempfile.mkdtemp(prefix="ixg_test_media_"))
def test_publish_coa_creates_buyer_document_and_notification(self):
qc = QCResult.objects.create(
batch=self.batch, ffa_percentage=Decimal("2.0"), moisture_percentage=Decimal("0.05"),
peroxide_value=Decimal("5.0"), visual_grade="A", overall_pass=True, entered_by=self.ops_a,
spec_version="1.0",
)
coa = services.create_coa_for_qc(qc, approved_by=self.ops_b)
services.publish_coa(coa, released_by=self.ops_b)
coa.refresh_from_db()
self.assertEqual(coa.status, CertificateOfAnalysis.Status.PUBLISHED)
buyer_doc = Document.objects.filter(batch=self.batch, doc_type="coa", visibility="buyer")
self.assertEqual(buyer_doc.count(), 1)
self.assertEqual(buyer_doc.first().file_url, coa.file_url)
self.assertTrue(Notification.objects.filter(user=self.buyer, type="coa_published").exists())
def test_buyer_verification_forbidden(self):
qc = QCResult.objects.create(
batch=self.batch, ffa_percentage=Decimal("2.0"), moisture_percentage=Decimal("0.05"),
peroxide_value=Decimal("5.0"), visual_grade="A", overall_pass=True, entered_by=self.ops_a,
)
self.client.force_authenticate(user=self.buyer)
resp = self.client.post(f"/api/qc-results/{qc.id}/verify/", {"approved": "true"}, format="json")
self.assertEqual(resp.status_code, 403)
class EUDRWorkflowTests(TestCase):
def setUp(self):
self.ops = make_user("ops.eudr@ixg.local", "ops")
self.buyer = make_user("buyer.eudr@ixg.local", "buyer")
self.batch = make_batch(self.ops)
def test_completeness_missing_lineage_blocks(self):
case = DueDiligenceCase.objects.create(batch=self.batch)
services.run_completeness_check(case)
case.refresh_from_db()
self.assertEqual(case.status, DueDiligenceCase.Status.INCOMPLETE)
self.assertFalse(case.completeness_results["geolocation"]["pass"])
self.assertFalse(case.completeness_results["lineage"]["pass"])
def test_completeness_unverified_zone_blocks(self):
facility = Facility.objects.create(name="ASI Katsina", country="Nigeria", region="Katsina")
zone = OriginZone.objects.create(
facility=facility, zone_code="KAT-01", country="Nigeria", region="Katsina",
verification_status=OriginZone.VerificationStatus.UNVERIFIED,
)
group = CollectorGroup.objects.create(name="Group A", origin_zone=zone)
intake = HarvestIntake.objects.create(
intake_reference="INT-001", collector_group=group, origin_zone=zone,
intake_date="2026-01-10", weight_kg=Decimal("500.00"),
)
IntakeBatchLineage.objects.create(batch=self.batch, harvest_intake=intake, weight_allocated_kg=Decimal("500.00"))
case = services.ensure_due_diligence_case(self.batch)
services.run_completeness_check(case)
case.refresh_from_db()
self.assertEqual(case.status, DueDiligenceCase.Status.INCOMPLETE)
self.assertFalse(case.completeness_results["geolocation"]["pass"])
def test_verified_zone_with_lineage_passes_geolocation(self):
facility = Facility.objects.create(name="ASI Katsina", country="Nigeria", region="Katsina")
zone = OriginZone.objects.create(
facility=facility, zone_code="KAT-02", country="Nigeria", region="Katsina",
verification_status=OriginZone.VerificationStatus.VERIFIED,
)
group = CollectorGroup.objects.create(name="Group B", origin_zone=zone)
intake = HarvestIntake.objects.create(
intake_reference="INT-002", collector_group=group, origin_zone=zone,
intake_date="2026-01-10", weight_kg=Decimal("500.00"),
)
IntakeBatchLineage.objects.create(batch=self.batch, harvest_intake=intake, weight_allocated_kg=Decimal("500.00"))
case = services.ensure_due_diligence_case(self.batch)
services.run_completeness_check(case)
case.refresh_from_db()
self.assertTrue(case.completeness_results["geolocation"]["pass"])
self.assertTrue(case.completeness_results["lineage"]["pass"])
def test_risk_high_without_lineage(self):
case = DueDiligenceCase.objects.create(batch=self.batch)
risk = services.evaluate_risk(case, user=self.ops)
case.refresh_from_db()
self.assertEqual(risk.risk_level, "high")
self.assertEqual(case.risk_level, DueDiligenceCase.RiskLevel.HIGH)
self.assertEqual(case.status, DueDiligenceCase.Status.MITIGATION_REQUIRED)
def test_approve_case_requires_complete_state(self):
case = DueDiligenceCase.objects.create(batch=self.batch)
with self.assertRaises(ValueError):
services.approve_case(case, self.ops)
def test_record_submission(self):
case = DueDiligenceCase.objects.create(batch=self.batch)
ref = services.record_submission(case, self.ops, "DD-2026-0001", "2026-08-05")
self.assertEqual(ref.dds_reference, "DD-2026-0001")
self.assertEqual(case.statement_references.count(), 1)
class BuyerIsolationTests(TestCase):
def setUp(self):
self.buyer = make_user("iso.buyer@ixg.local", "buyer")
self.ops = make_user("iso.ops@ixg.local", "ops")
self.client = APIClient()
def test_buyer_cannot_access_compliance_api(self):
self.client.force_authenticate(user=self.buyer)
resp = self.client.get("/api/product-specs/")
self.assertEqual(resp.status_code, 403)
resp = self.client.get("/api/eudr-cases/")
self.assertEqual(resp.status_code, 403)
def test_ops_can_access_compliance_api(self):
self.client.force_authenticate(user=self.ops)
resp = self.client.get("/api/product-specs/")
self.assertEqual(resp.status_code, 200)
resp = self.client.get("/api/eudr-cases/")
self.assertEqual(resp.status_code, 200)
def test_buyer_forbidden_from_ops(self):
self.client.force_login(self.buyer)
resp = self.client.get("/ops/")
self.assertEqual(resp.status_code, 403)
class PageRenderTests(TestCase):
def setUp(self):
self.ops = make_user("page.ops@ixg.local", "ops")
self.buyer = make_user("page.buyer@ixg.local", "buyer")
self.batch = make_batch(self.ops)
self.qc = QCResult.objects.create(
batch=self.batch, ffa_percentage=Decimal("2.0"), moisture_percentage=Decimal("0.05"),
peroxide_value=Decimal("5.0"), visual_grade="A", overall_pass=True,
entered_by=self.ops, spec_version="1.0",
)
self.coa = services.create_coa_for_qc(self.qc, approved_by=self.ops)
self.case = DueDiligenceCase.objects.create(batch=self.batch)
def test_ops_pages_render(self):
self.client.force_login(self.ops)
for url in [
"/ops/qc/verify/",
f"/ops/qc/verify/{self.qc.id}/",
"/ops/coa/",
f"/ops/coa/{self.coa.id}/",
"/ops/eudr/",
f"/ops/eudr/{self.case.id}/",
]:
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200, msg=f"GET {url}")
def test_buyer_compliance_page_renders(self):
self.client.force_login(self.buyer)
resp = self.client.get("/portal/compliance/")
self.assertEqual(resp.status_code, 200)
def test_ops_post_publish_coa(self):
self.client.force_login(self.ops)
resp = self.client.post(f"/ops/coa/{self.coa.id}/", {"action": "approve"})
self.assertEqual(resp.status_code, 302)
self.coa.refresh_from_db()
self.assertEqual(self.coa.status, CertificateOfAnalysis.Status.PUBLISHED)