test_catalog_execution.py 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. from __future__ import annotations
  2. from dataclasses import asdict
  3. import pytest
  4. from tests.data_research.test_ingestion_service import (
  5. MemoryJobRepository,
  6. payload,
  7. )
  8. class MemorySnapshotRepository:
  9. def __init__(self):
  10. self.records = {}
  11. def find(self, job_uid, attempt):
  12. return self.records.get((str(job_uid), int(attempt)))
  13. def persist(self, job_uid, attempt, snapshot):
  14. from app.core.data_research.catalog.models import CatalogSnapshotRecord
  15. key = (str(job_uid), int(attempt))
  16. existing = self.records.get(key)
  17. if existing is not None:
  18. return existing
  19. field_count = sum(len(asset.fields) for asset in snapshot.assets)
  20. record = CatalogSnapshotRecord(
  21. uid=f"snapshot-{attempt}",
  22. job_uid=str(job_uid),
  23. source_uid=snapshot.data_source_uid,
  24. attempt=int(attempt),
  25. database_type=snapshot.database_type,
  26. content_hash=snapshot.content_hash,
  27. snapshot=asdict(snapshot),
  28. evidence_count=field_count,
  29. )
  30. self.records[key] = record
  31. return record
  32. def list(self, job_uid):
  33. return [
  34. record
  35. for (candidate_uid, _attempt), record in sorted(self.records.items())
  36. if candidate_uid == str(job_uid)
  37. ]
  38. def catalog_snapshot():
  39. from app.core.data_research.catalog.models import (
  40. CatalogAsset,
  41. CatalogField,
  42. CatalogSnapshot,
  43. )
  44. return CatalogSnapshot(
  45. data_source_uid="00000000-0000-0000-0000-000000000001",
  46. database_type="postgresql",
  47. assets=(
  48. CatalogAsset(
  49. key="source:asset.equipment",
  50. schema="asset",
  51. name="equipment",
  52. asset_type="table",
  53. fields=(
  54. CatalogField(
  55. key="source:asset.equipment.equipment_code",
  56. schema="asset",
  57. asset="equipment",
  58. name="equipment_code",
  59. ordinal_position=1,
  60. data_type="varchar",
  61. nullable=False,
  62. ),
  63. CatalogField(
  64. key="source:asset.equipment.location_code",
  65. schema="asset",
  66. asset="equipment",
  67. name="location_code",
  68. ordinal_position=2,
  69. data_type="varchar",
  70. nullable=True,
  71. ),
  72. ),
  73. ),
  74. ),
  75. )
  76. class CatalogCollector:
  77. def __init__(self, result=None, error=None):
  78. self.result = result or catalog_snapshot()
  79. self.error = error
  80. self.calls = []
  81. def collect(self, source_uid, scope):
  82. self.calls.append((source_uid, scope))
  83. if self.error is not None:
  84. raise self.error
  85. return self.result
  86. def catalog_payload(**overrides):
  87. return payload(
  88. artifact_uid=None,
  89. job_type="catalog_collect",
  90. parser_version="catalog-v1",
  91. parameters={
  92. "include_schemas": ["asset"],
  93. "exclude_schemas": ["pg_catalog"],
  94. "include_tables": ["equipment"],
  95. "exclude_tables": [],
  96. },
  97. **overrides,
  98. )
  99. def test_catalog_execution_persists_snapshot_evidence_and_job_report():
  100. from app.core.data_research.catalog.execution import CatalogIngestionExecutor
  101. from app.core.data_research.ingestion import IngestionService
  102. jobs = MemoryJobRepository()
  103. ingestion = IngestionService(jobs)
  104. job, _ = ingestion.create_job(catalog_payload(), actor_uid="editor-1")
  105. collector = CatalogCollector()
  106. snapshots = MemorySnapshotRepository()
  107. executor = CatalogIngestionExecutor(ingestion, collector, snapshots)
  108. completed = executor.execute(job.uid)
  109. assert completed.status == "awaiting_review"
  110. assert completed.attempt_count == 1
  111. assert completed.statistics == {
  112. "snapshot_uid": "snapshot-1",
  113. "content_hash": catalog_snapshot().content_hash,
  114. "asset_count": 1,
  115. "field_count": 2,
  116. "evidence_count": 2,
  117. }
  118. assert snapshots.find(job.uid, 1).attempt == 1
  119. assert collector.calls[0][1].include_schemas == ("asset",)
  120. replayed = executor.execute(job.uid)
  121. assert replayed == completed
  122. assert len(collector.calls) == 1
  123. def test_failed_catalog_collection_records_stage_and_can_retry():
  124. from app.core.data_research.catalog.execution import CatalogIngestionExecutor
  125. from app.core.data_research.ingestion import IngestionService
  126. jobs = MemoryJobRepository()
  127. ingestion = IngestionService(jobs)
  128. job, _ = ingestion.create_job(catalog_payload(), actor_uid="editor-1")
  129. collector = CatalogCollector(
  130. error=RuntimeError("password=clear-secret connection refused")
  131. )
  132. snapshots = MemorySnapshotRepository()
  133. executor = CatalogIngestionExecutor(ingestion, collector, snapshots)
  134. with pytest.raises(RuntimeError, match="connection refused"):
  135. executor.execute(job.uid)
  136. failed = ingestion.get_job(job.uid)
  137. assert failed.status == "failed"
  138. assert failed.attempt_count == 1
  139. assert failed.failure_stage == "extracting"
  140. assert "clear-secret" not in failed.last_error
  141. assert snapshots.list(job.uid) == []
  142. ingestion.retry(job.uid)
  143. collector.error = None
  144. completed = executor.execute(job.uid)
  145. assert completed.status == "awaiting_review"
  146. assert completed.attempt_count == 2
  147. assert snapshots.find(job.uid, 2).attempt == 2
  148. def test_catalog_scope_rejects_non_list_or_non_text_values_before_collection():
  149. from app.core.data_research.catalog.execution import catalog_scope_from_parameters
  150. from app.core.data_research.errors import IngestionPayloadInvalid
  151. with pytest.raises(IngestionPayloadInvalid, match="include_schemas"):
  152. catalog_scope_from_parameters({"include_schemas": "asset"})
  153. with pytest.raises(IngestionPayloadInvalid, match="include_tables"):
  154. catalog_scope_from_parameters({"include_tables": ["equipment", 42]})