""" Tests para generate_report_document (T2026-04-001). Ejecución: python manage.py test api.reports.tests python manage.py test api.reports.tests.TestEstadoHelper """ import io import uuid from unittest.mock import MagicMock, call, patch import openpyxl from django.contrib.auth import get_user_model from django.db.models import Q from django.test import TestCase from api.customs.models import Cove, EDocument, Importador, Partida, Pedimento from api.licence.models import Licencia from api.organization.models import Organizacion from api.reports.models import ReportDocument from api.reports.tasks.report_document import ( _apply_user_rfc_filter, _estado, generate_report_document, ) User = get_user_model() FAKE_PATH = 'reports/test/reporte.xlsx' # ── fixtures ────────────────────────────────────────────────────────────────── def _licencia(nombre='Plan Test'): return Licencia.objects.create(nombre=nombre, almacenamiento=10) def _org(nombre='Org Test'): lic = _licencia(f'Lic {nombre}') return Organizacion.objects.create(nombre=nombre, is_active=True, is_verified=True, licencia=lic) def _user(org, username='tuser', rfcs=None): u = User.objects.create_user(username=username, password='pass', organizacion=org) if rfcs: u.rfc.set(rfcs) return u def _imp(org, rfc='RFC000000001', nombre='Importador Test'): return Importador.objects.create(rfc=rfc, nombre=nombre, organizacion=org) def _ped(org, imp=None, num='0000001'): return Pedimento.objects.create( pedimento=num, pedimento_app=f'25-160-3910-{num}', organizacion=org, contribuyente=imp, aduana='160', patente='3910', regimen='ITE', clave_pedimento='A1', ) def _reporte(user, org_id, extra=None): filtros = {'organizacion_id': str(org_id)} if extra: filtros.update(extra) return ReportDocument.objects.create( user=user, filters=filtros, status='pending', report_type='cumplimiento' ) def _excel_desde_mock(mock_save): """Parsea el workbook que recibió storage_service.save_report.""" uf = mock_save.call_args[1]['file'] return openpyxl.load_workbook(io.BytesIO(uf.read())) def _docs_col(ws): """Devuelve {documento: estatus} leyendo columnas 9 y 10 del worksheet.""" return { ws.cell(row=r, column=9).value: ws.cell(row=r, column=10).value for r in range(1, ws.max_row + 1) if ws.cell(row=r, column=9).value } def _col1_values(ws): """Devuelve todos los valores no vacíos de la columna 1.""" return [ str(ws.cell(row=r, column=1).value) for r in range(1, ws.max_row + 1) if ws.cell(row=r, column=1).value ] # ── 1. Helpers ──────────────────────────────────────────────────────────────── class TestEstadoHelper(TestCase): def test_true_retorna_recuperado(self): self.assertEqual(_estado(True), 'RECUPERADO') def test_false_retorna_pendiente(self): self.assertEqual(_estado(False), 'PENDIENTE') # ── 2. Filtro de RFC por usuario ────────────────────────────────────────────── class TestApplyUserRfcFilter(TestCase): @classmethod def setUpTestData(cls): cls.org = _org() cls.imp1 = _imp(cls.org, rfc='RFC000000001') cls.imp2 = _imp(cls.org, rfc='RFC000000002') def test_sin_rfcs_asignados_sin_filtro_retorna_q_vacio(self): user = _user(self.org, username='u_admin') q = _apply_user_rfc_filter(Q(), user, None) self.assertEqual(str(q), str(Q())) def test_sin_rfcs_asignados_con_filtro_explicito_aplica_filtro(self): user = _user(self.org, username='u_admin2') q = _apply_user_rfc_filter(Q(), user, 'RFC000000001') self.assertIn('RFC000000001', str(q)) def test_con_rfcs_sin_filtro_restringe_a_sus_importadores(self): user = _user(self.org, username='u_imp1', rfcs=[self.imp1]) q = _apply_user_rfc_filter(Q(), user, None) self.assertIn('contribuyente', str(q)) def test_con_rfcs_pide_el_suyo_se_filtra_por_ese_rfc(self): user = _user(self.org, username='u_imp2', rfcs=[self.imp1]) q = _apply_user_rfc_filter(Q(), user, 'RFC000000001') self.assertIn('RFC000000001', str(q)) def test_con_rfcs_pide_ajeno_se_usa_el_suyo_no_el_solicitado(self): user = _user(self.org, username='u_imp3', rfcs=[self.imp1]) q = _apply_user_rfc_filter(Q(), user, 'RFC000000002') self.assertNotIn('RFC000000002', str(q)) self.assertIn('contribuyente', str(q)) # ── 3. Tarea completa ───────────────────────────────────────────────────────── # Todos los tests en esta clase mockean Redis (publish_task_event) y MinIO # (storage_service.save_report) para no depender de infraestructura externa. @patch('api.reports.tasks.report_document.publish_task_event') @patch('api.reports.tasks.report_document.storage_service.save_report', return_value=FAKE_PATH) class TestGenerateReportDocument(TestCase): @classmethod def setUpTestData(cls): cls.org = _org('Org Reporte') cls.imp = _imp(cls.org, rfc='MTK8610143000', nombre='Servicios TETAKAWI') cls.user = _user(cls.org, username='rep_user') def _run(self, report): generate_report_document.apply(args=[str(report.id)]) report.refresh_from_db() # ── 3.1 Sin pedimentos ──────────────────────────────────────────────────── def test_sin_pedimentos_genera_excel_vacio_y_status_ready(self, mock_save, mock_pub): report = _reporte(self.user, self.org.id) self._run(report) self.assertEqual(report.status, 'ready') self.assertEqual(report.file, FAKE_PATH) mock_save.assert_called_once() # El workbook no debe tener datos de RFCs wb = _excel_desde_mock(mock_save) ws = wb.active col1 = _col1_values(ws) self.assertFalse(col1, 'Excel vacío no debe tener contenido en col 1') # ── 3.2 RFC aparece en encabezado ───────────────────────────────────────── def test_rfc_del_importador_aparece_en_excel(self, mock_save, mock_pub): _ped(self.org, self.imp, '1000001') report = _reporte(self.user, self.org.id) self._run(report) self.assertEqual(report.status, 'ready') wb = _excel_desde_mock(mock_save) ws = wb.active col1 = ' '.join(_col1_values(ws)) self.assertIn('MTK8610143000', col1) # ── 3.3 PEDIMENTO COMPLETO ──────────────────────────────────────────────── def test_pedimento_completo_recuperado_cuando_existe_expediente(self, mock_save, mock_pub): ped = _ped(self.org, self.imp, '1000002') ped.existe_expediente = True ped.save(update_fields=['existe_expediente']) report = _reporte(self.user, self.org.id) self._run(report) docs = _docs_col(_excel_desde_mock(mock_save).active) self.assertEqual(docs.get('PEDIMENTO COMPLETO'), 'RECUPERADO') def test_pedimento_completo_pendiente_cuando_no_tiene_expediente(self, mock_save, mock_pub): _ped(self.org, self.imp, '1000003') # existe_expediente=False por default report = _reporte(self.user, self.org.id) self._run(report) docs = _docs_col(_excel_desde_mock(mock_save).active) self.assertEqual(docs.get('PEDIMENTO COMPLETO'), 'PENDIENTE') # ── 3.4 Partidas ────────────────────────────────────────────────────────── def test_partidas_con_estado_correcto(self, mock_save, mock_pub): ped = _ped(self.org, self.imp, '1000004') Partida.objects.create( pedimento=ped, organizacion=self.org, numero_partida=1, descargado=True ) Partida.objects.create( pedimento=ped, organizacion=self.org, numero_partida=2, descargado=False ) report = _reporte(self.user, self.org.id) self._run(report) docs = _docs_col(_excel_desde_mock(mock_save).active) self.assertEqual(docs.get('PARTIDA1'), 'RECUPERADO') self.assertEqual(docs.get('PARTIDA2'), 'PENDIENTE') # ── 3.5 COVEs y acuses ──────────────────────────────────────────────────── def test_cove_y_acuse_con_estados_distintos(self, mock_save, mock_pub): ped = _ped(self.org, self.imp, '1000005') Cove.objects.create( pedimento=ped, organizacion=self.org, numero_cove='654001', cove_descargado=True, acuse_cove_descargado=False, ) report = _reporte(self.user, self.org.id) self._run(report) docs = _docs_col(_excel_desde_mock(mock_save).active) self.assertEqual(docs.get('COVE654001'), 'RECUPERADO') self.assertEqual(docs.get('ACUSE COVE654001'), 'PENDIENTE') # ── 3.6 EDocumentos y acuses ────────────────────────────────────────────── def test_edocumento_y_acuse_con_estados_distintos(self, mock_save, mock_pub): ped = _ped(self.org, self.imp, '1000006') EDocument.objects.create( pedimento=ped, organizacion=self.org, numero_edocument='EDOC001', edocument_descargado=False, acuse_descargado=True, ) report = _reporte(self.user, self.org.id) self._run(report) docs = _docs_col(_excel_desde_mock(mock_save).active) self.assertEqual(docs.get('EDOCUMENTOEDOC001'), 'PENDIENTE') self.assertEqual(docs.get('ACUSE EDOCUMENTOEDOC001'), 'RECUPERADO') # ── 3.7 Remesa ──────────────────────────────────────────────────────────── def test_remesa_recuperada_cuando_document_tipo_15_existe(self, mock_save, mock_pub): """Pedimento.remesas=True y el query de Document devuelve el pedimento_id.""" ped = Pedimento.objects.create( pedimento='1000007', pedimento_app='25-160-3910-1000007', organizacion=self.org, contribuyente=self.imp, aduana='160', patente='3910', remesas=True, ) report = _reporte(self.user, self.org.id) # Patch solo el query de Document dentro del task with patch('api.reports.tasks.report_document.Document') as MockDoc: mock_qs = MagicMock() mock_qs.values_list.return_value = [ped.id] MockDoc.objects.filter.return_value = mock_qs self._run(report) docs = _docs_col(_excel_desde_mock(mock_save).active) self.assertEqual(docs.get('REMESA'), 'RECUPERADO') def test_remesa_pendiente_cuando_no_hay_document(self, mock_save, mock_pub): """Pedimento.remesas=True pero el query de Document devuelve lista vacía.""" Pedimento.objects.create( pedimento='1000008', pedimento_app='25-160-3910-1000008', organizacion=self.org, contribuyente=self.imp, aduana='160', patente='3910', remesas=True, ) report = _reporte(self.user, self.org.id) with patch('api.reports.tasks.report_document.Document') as MockDoc: mock_qs = MagicMock() mock_qs.values_list.return_value = [] MockDoc.objects.filter.return_value = mock_qs self._run(report) docs = _docs_col(_excel_desde_mock(mock_save).active) self.assertEqual(docs.get('REMESA'), 'PENDIENTE') def test_sin_remesa_no_aparece_fila_remesa(self, mock_save, mock_pub): """Pedimento.remesas=False → no debe aparecer fila REMESA.""" _ped(self.org, self.imp, '1000009') # remesas=False por default report = _reporte(self.user, self.org.id) self._run(report) docs = _docs_col(_excel_desde_mock(mock_save).active) self.assertNotIn('REMESA', docs) # ── 3.8 Múltiples RFCs ─────────────────────────────────────────────────── def test_multiples_rfcs_generan_secciones_separadas(self, mock_save, mock_pub): imp2 = _imp(self.org, rfc='TEC140624802', nombre='TEC Importaciones') _ped(self.org, self.imp, '1000010') _ped(self.org, imp2, '1000011') report = _reporte(self.user, self.org.id) self._run(report) self.assertEqual(report.status, 'ready') contenido = ' '.join(_col1_values(_excel_desde_mock(mock_save).active)) self.assertIn('MTK8610143000', contenido) self.assertIn('TEC140624802', contenido) # ── 3.9 Restricción por RFC de usuario ─────────────────────────────────── def test_importador_solo_ve_sus_pedimentos(self, mock_save, mock_pub): imp2 = _imp(self.org, rfc='XYZ999999999', nombre='Externo') _ped(self.org, self.imp, '1000012') _ped(self.org, imp2, '1000013') user_restr = _user(self.org, username='u_restr', rfcs=[self.imp]) report = _reporte(user_restr, self.org.id) self._run(report) self.assertEqual(report.status, 'ready') contenido = ' '.join(_col1_values(_excel_desde_mock(mock_save).active)) self.assertIn('MTK8610143000', contenido) self.assertNotIn('XYZ999999999', contenido) # ── 3.10 Formato del archivo ────────────────────────────────────────────── def test_archivo_descargado_es_xlsx_valido(self, mock_save, mock_pub): _ped(self.org, self.imp, '1000014') report = _reporte(self.user, self.org.id) self._run(report) uf = mock_save.call_args[1]['file'] self.assertTrue(uf.name.endswith('.xlsx'), f'Esperado .xlsx, recibido: {uf.name}') try: wb = openpyxl.load_workbook(io.BytesIO(uf.read())) self.assertIsNotNone(wb) except Exception as exc: self.fail(f'Excel no es válido: {exc}') def test_cabeceras_de_columna_presentes(self, mock_save, mock_pub): _ped(self.org, self.imp, '1000015') report = _reporte(self.user, self.org.id) self._run(report) ws = _excel_desde_mock(mock_save).active cabeceras = None for r in range(1, ws.max_row + 1): if ws.cell(row=r, column=1).value == 'Año': cabeceras = [ws.cell(row=r, column=c).value for c in range(1, 11)] break self.assertIsNotNone(cabeceras, 'No se encontró la fila de cabeceras') for col in ('Año', 'Aduana', 'Patente', 'Pedimento', 'Documento', 'Estatus'): self.assertIn(col, cabeceras, f'Cabecera "{col}" no encontrada') # ── 3.11 Progreso en Redis ──────────────────────────────────────────────── def test_se_publican_eventos_de_progreso(self, mock_save, mock_pub): _ped(self.org, self.imp, '1000016') report = _reporte(self.user, self.org.id) self._run(report) self.assertGreaterEqual(mock_pub.call_count, 4, 'Se esperan mínimo 4 eventos') def test_ultimo_evento_es_completed_con_100(self, mock_save, mock_pub): _ped(self.org, self.imp, '1000017') report = _reporte(self.user, self.org.id) self._run(report) ultimo = mock_pub.call_args_list[-1] self.assertEqual(ultimo[0][1], 'completed') self.assertEqual(ultimo[1].get('progress'), 100) # ── 3.12 Manejo de errores ──────────────────────────────────────────────── def test_storage_none_deja_status_error(self, mock_save, mock_pub): """storage_service.save_report retorna None → report queda en error.""" mock_save.return_value = None _ped(self.org, self.imp, '1000018') report = _reporte(self.user, self.org.id) self._run(report) self.assertEqual(report.status, 'error') self.assertIn('almacenamiento', report.error_message) def test_storage_none_publica_evento_failed(self, mock_save, mock_pub): mock_save.return_value = None _ped(self.org, self.imp, '1000019') report = _reporte(self.user, self.org.id) self._run(report) statuses = [c[0][1] for c in mock_pub.call_args_list] self.assertIn('failed', statuses) self.assertNotIn('completed', statuses) def test_excepcion_guarda_traceback_en_error_message(self, mock_save, mock_pub): """Una excepción inesperada debe incluir traceback en error_message.""" mock_save.side_effect = RuntimeError('Fallo simulado de MinIO') _ped(self.org, self.imp, '1000020') report = _reporte(self.user, self.org.id) try: generate_report_document.apply(args=[str(report.id)]) except RuntimeError: pass # apply() re-raise la excepción report.refresh_from_db() self.assertEqual(report.status, 'error') self.assertIn('Fallo simulado de MinIO', report.error_message) self.assertIn('Traceback', report.error_message) def test_excepcion_publica_evento_failed(self, mock_save, mock_pub): mock_save.side_effect = RuntimeError('Error MinIO') _ped(self.org, self.imp, '1000021') report = _reporte(self.user, self.org.id) try: generate_report_document.apply(args=[str(report.id)]) except RuntimeError: pass statuses = [c[0][1] for c in mock_pub.call_args_list] self.assertIn('failed', statuses)