diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9cdb0e3..f75df85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,7 @@ on: branches: - main - project-foundation + - source-fixture-api jobs: verify: @@ -36,8 +37,8 @@ jobs: - name: Install dependencies run: uv sync --locked --python "${{ matrix.python-version }}" - - name: Run targeted tests - run: uv run pytest tests/test_config.py tests/test_models.py tests/test_cli.py -v + - name: Run tests + run: uv run pytest -v - name: Run ruff run: uv run ruff check src tests @@ -47,7 +48,7 @@ jobs: - name: Run coverage gate run: > - uv run pytest tests/test_config.py tests/test_models.py tests/test_cli.py + uv run pytest --cov=clean_data_export_api --cov-report=term-missing --cov-fail-under=90 diff --git a/sample_data/source_job_updates.json b/sample_data/source_job_updates.json new file mode 100644 index 0000000..bd6d7ae --- /dev/null +++ b/sample_data/source_job_updates.json @@ -0,0 +1,58 @@ +[ + { + "update_id": "update-001", + "job_id": "JOB-1001", + "status": "scheduled", + "note": "Appointment confirmed.", + "updated_at": "2026-06-01T14:00:00Z" + }, + { + "update_id": "update-002", + "job_id": "JOB-1001", + "status": "in_progress", + "note": "Technician dispatched.", + "updated_at": "2026-06-02T08:45:00Z" + }, + { + "update_id": "update-003", + "job_id": "JOB-1002", + "status": "in_progress", + "note": "Parts received.", + "updated_at": "2026-06-03T10:15:00Z" + }, + { + "update_id": "update-004", + "job_id": "JOB-1003", + "status": "completed", + "note": "Customer signed off.", + "updated_at": "2026-06-04T16:30:00Z" + }, + { + "update_id": "update-005", + "job_id": "JOB-1006", + "status": "waiting", + "note": "Waiting for customer confirmation.", + "updated_at": "2026-06-04T12:00:00Z" + }, + { + "update_id": "update-006", + "job_id": "JOB-1008", + "status": "scheduled", + "note": "Work type missing in source record.", + "updated_at": "2026-06-05T09:00:00Z" + }, + { + "update_id": "update-007", + "job_id": "JOB-9999", + "status": "scheduled", + "note": "Unrelated update used to verify filtering.", + "updated_at": "2026-06-05T11:00:00Z" + }, + { + "update_id": "update-008", + "job_id": "JOB-2001", + "status": "scheduled", + "note": "Outside the demo date range.", + "updated_at": "2026-05-31T16:00:00Z" + } +] diff --git a/sample_data/source_jobs.json b/sample_data/source_jobs.json new file mode 100644 index 0000000..d5ae0ee --- /dev/null +++ b/sample_data/source_jobs.json @@ -0,0 +1,101 @@ +[ + { + "source_row_id": "row-001", + "job_id": "JOB-1001", + "customer_name": "Avery Plumbing", + "status": "scheduled", + "work_type": "repair", + "scheduled_date": "2026-06-02", + "updated_at": "2026-06-01T09:15:00Z" + }, + { + "source_row_id": "row-002", + "job_id": "JOB-1002", + "customer_name": "Northstar Dental", + "status": "in_progress", + "work_type": "installation", + "scheduled_date": "2026-06-03", + "updated_at": "2026-06-02T10:30:00Z" + }, + { + "source_row_id": "row-003", + "job_id": "JOB-1003", + "customer_name": "Harbor Cafe", + "status": "completed", + "work_type": "inspection", + "scheduled_date": "2026-06-04", + "updated_at": "2026-06-03T11:45:00Z" + }, + { + "source_row_id": "row-004", + "job_id": null, + "customer_name": "Brightline Salon", + "status": "scheduled", + "work_type": "repair", + "scheduled_date": "2026-06-04", + "updated_at": "2026-06-03T13:00:00Z" + }, + { + "source_row_id": "row-005", + "job_id": "JOB-1005", + "customer_name": "", + "status": "scheduled", + "work_type": "maintenance", + "scheduled_date": "2026-06-05", + "updated_at": "2026-06-04T08:20:00Z" + }, + { + "source_row_id": "row-006", + "job_id": "JOB-1006", + "customer_name": "Cedar Books", + "status": "waiting", + "work_type": "repair", + "scheduled_date": "2026-06-05", + "updated_at": "2026-06-04T09:10:00Z" + }, + { + "source_row_id": "row-007", + "job_id": "JOB-1007", + "customer_name": "Pioneer Fitness", + "status": "scheduled", + "work_type": "inspection", + "scheduled_date": "2026-15-01", + "updated_at": "2026-06-04T14:30:00Z" + }, + { + "source_row_id": "row-008", + "job_id": "JOB-1008", + "customer_name": "Metro Florist", + "status": "scheduled", + "work_type": null, + "scheduled_date": "2026-06-05", + "updated_at": "2026-06-05T08:00:00Z" + }, + { + "source_row_id": "row-009", + "job_id": "JOB-1002", + "customer_name": "Northstar Dental", + "status": "in_progress", + "work_type": "installation", + "scheduled_date": "2026-06-03", + "updated_at": "2026-06-05T12:00:00Z" + }, + { + "source_row_id": "row-010", + "job_id": "JOB-2001", + "customer_name": "Westside Market", + "status": "scheduled", + "work_type": "maintenance", + "scheduled_date": "2026-05-31", + "updated_at": "2026-05-31T15:00:00Z" + }, + { + "source_row_id": "row-011", + "job_id": "JOB-2002", + "customer_name": "Summit Legal", + "status": "scheduled", + "work_type": "inspection", + "scheduled_date": "2026-06-07", + "updated_at": "2026-06-06T09:00:00Z" + } +] diff --git a/src/clean_data_export_api/source_api.py b/src/clean_data_export_api/source_api.py new file mode 100644 index 0000000..669d1bd --- /dev/null +++ b/src/clean_data_export_api/source_api.py @@ -0,0 +1,105 @@ +"""Local fixture API for fictional FieldOps Desk records.""" + +import json +from collections.abc import Iterable +from dataclasses import dataclass +from datetime import date, datetime +from pathlib import Path +from typing import Any + +from clean_data_export_api.config import RuntimeSettings, get_default_settings +from clean_data_export_api.models import SourceJob, SourceJobUpdate + +SOURCE_JOBS_FILE = "source_jobs.json" +SOURCE_JOB_UPDATES_FILE = "source_job_updates.json" + + +@dataclass(frozen=True) +class SourceJobPage: + """One deterministic page of source job records.""" + + items: tuple[SourceJob, ...] + next_cursor: int | None + + +class FixtureSourceAPI: + """Read local fixture files through a small paginated API surface.""" + + def __init__(self, settings: RuntimeSettings | None = None) -> None: + self._settings = settings or get_default_settings() + + def list_jobs( + self, + *, + from_date: date, + to_date: date, + cursor: int = 0, + page_size: int | None = None, + ) -> SourceJobPage: + """Return one page of jobs whose source updated date is in range.""" + if from_date > to_date: + msg = "from_date must be before or equal to to_date" + raise ValueError(msg) + + resolved_page_size = self._settings.page_size if page_size is None else page_size + if resolved_page_size <= 0: + msg = "page_size must be greater than zero" + raise ValueError(msg) + if cursor < 0: + msg = "cursor must be greater than or equal to zero" + raise ValueError(msg) + + matching_jobs = tuple( + job + for job in load_source_jobs(self._settings.sample_data_dir) + if from_date <= _source_updated_date(job) <= to_date + ) + page_items = matching_jobs[cursor : cursor + resolved_page_size] + next_cursor = cursor + resolved_page_size + if next_cursor >= len(matching_jobs): + next_cursor = None + + return SourceJobPage(items=page_items, next_cursor=next_cursor) + + def list_job_updates(self, job_ids: Iterable[str]) -> tuple[SourceJobUpdate, ...]: + """Return updates for the requested job ids in fixture order.""" + requested_job_ids = set(job_ids) + return tuple( + update + for update in load_source_job_updates(self._settings.sample_data_dir) + if update.job_id in requested_job_ids + ) + + +def load_source_jobs(sample_data_dir: Path) -> tuple[SourceJob, ...]: + """Load source jobs from the configured fixture directory.""" + raw_records = _load_json_array(sample_data_dir / SOURCE_JOBS_FILE) + return tuple(SourceJob.model_validate(record) for record in raw_records) + + +def load_source_job_updates(sample_data_dir: Path) -> tuple[SourceJobUpdate, ...]: + """Load source job updates from the configured fixture directory.""" + raw_records = _load_json_array(sample_data_dir / SOURCE_JOB_UPDATES_FILE) + return tuple(SourceJobUpdate.model_validate(record) for record in raw_records) + + +def _load_json_array(path: Path) -> list[dict[str, Any]]: + raw_data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(raw_data, list): + msg = f"{path} must contain a JSON array" + raise ValueError(msg) + + records: list[dict[str, Any]] = [] + for record in raw_data: + if not isinstance(record, dict): + msg = f"{path} must contain JSON objects" + raise ValueError(msg) + records.append(record) + return records + + +def _source_updated_date(job: SourceJob) -> date: + if not job.updated_at: + msg = f"source job {job.source_row_id} is missing updated_at" + raise ValueError(msg) + return datetime.fromisoformat(job.updated_at.replace("Z", "+00:00")).date() diff --git a/tests/test_sample_data.py b/tests/test_sample_data.py new file mode 100644 index 0000000..53a31a7 --- /dev/null +++ b/tests/test_sample_data.py @@ -0,0 +1,77 @@ +import json +from datetime import date, datetime +from pathlib import Path +from typing import Any + +from clean_data_export_api.models import SourceJob, SourceJobUpdate + +SOURCE_JOBS_PATH = Path("sample_data/source_jobs.json") +SOURCE_JOB_UPDATES_PATH = Path("sample_data/source_job_updates.json") +DEMO_FROM_DATE = date(2026, 6, 1) +DEMO_TO_DATE = date(2026, 6, 5) + + +def test_source_job_fixture_matches_demo_contract() -> None: + records = _load_json_array(SOURCE_JOBS_PATH) + jobs = tuple(SourceJob.model_validate(record) for record in records) + jobs_in_demo_range = tuple( + job for job in jobs if DEMO_FROM_DATE <= _source_updated_date(job) <= DEMO_TO_DATE + ) + + assert len(jobs) == 11 + assert len(jobs_in_demo_range) == 9 + assert [job.source_row_id for job in jobs_in_demo_range] == [ + "row-001", + "row-002", + "row-003", + "row-004", + "row-005", + "row-006", + "row-007", + "row-008", + "row-009", + ] + + +def test_source_job_fixture_contains_required_record_types() -> None: + jobs = tuple(SourceJob.model_validate(record) for record in _load_json_array(SOURCE_JOBS_PATH)) + job_ids = [job.job_id for job in jobs] + + assert None in job_ids + assert job_ids.count("JOB-1002") == 2 + assert any(job.customer_name == "" for job in jobs) + assert any(job.work_type is None for job in jobs) + assert any(job.status == "waiting" for job in jobs) + assert any(job.scheduled_date == "2026-15-01" for job in jobs) + assert any(_source_updated_date(job) < DEMO_FROM_DATE for job in jobs) + assert any(_source_updated_date(job) > DEMO_TO_DATE for job in jobs) + + +def test_source_job_update_fixture_is_valid_and_filterable() -> None: + records = _load_json_array(SOURCE_JOB_UPDATES_PATH) + updates = tuple(SourceJobUpdate.model_validate(record) for record in records) + + assert len(updates) == 8 + assert [update.update_id for update in updates] == [ + "update-001", + "update-002", + "update-003", + "update-004", + "update-005", + "update-006", + "update-007", + "update-008", + ] + assert {update.job_id for update in updates} >= {"JOB-1001", "JOB-1002", "JOB-1003"} + + +def _load_json_array(path: Path) -> list[dict[str, Any]]: + raw_data = json.loads(path.read_text(encoding="utf-8")) + assert isinstance(raw_data, list) + assert all(isinstance(record, dict) for record in raw_data) + return raw_data + + +def _source_updated_date(job: SourceJob) -> date: + assert job.updated_at is not None + return datetime.fromisoformat(job.updated_at.replace("Z", "+00:00")).date() diff --git a/tests/test_source_api.py b/tests/test_source_api.py new file mode 100644 index 0000000..6e4e8eb --- /dev/null +++ b/tests/test_source_api.py @@ -0,0 +1,158 @@ +import json +from datetime import date +from pathlib import Path + +import pytest + +from clean_data_export_api.config import RuntimeSettings +from clean_data_export_api.source_api import ( + FixtureSourceAPI, + load_source_job_updates, + load_source_jobs, +) + +DEMO_FROM_DATE = date(2026, 6, 1) +DEMO_TO_DATE = date(2026, 6, 5) + + +def test_load_source_jobs_returns_typed_fixture_records() -> None: + jobs = load_source_jobs(Path("sample_data")) + + assert len(jobs) == 11 + assert jobs[0].source_row_id == "row-001" + assert jobs[-1].source_row_id == "row-011" + + +def test_load_source_job_updates_returns_typed_fixture_records() -> None: + updates = load_source_job_updates(Path("sample_data")) + + assert len(updates) == 8 + assert updates[0].update_id == "update-001" + assert updates[-1].update_id == "update-008" + + +def test_list_jobs_filters_demo_date_range_in_fixture_order() -> None: + source_api = FixtureSourceAPI() + + page = source_api.list_jobs(from_date=DEMO_FROM_DATE, to_date=DEMO_TO_DATE) + + assert page.next_cursor is None + assert len(page.items) == 9 + assert [job.source_row_id for job in page.items] == [ + "row-001", + "row-002", + "row-003", + "row-004", + "row-005", + "row-006", + "row-007", + "row-008", + "row-009", + ] + + +def test_list_jobs_paginates_deterministically() -> None: + source_api = FixtureSourceAPI(RuntimeSettings(page_size=4)) + + first_page = source_api.list_jobs(from_date=DEMO_FROM_DATE, to_date=DEMO_TO_DATE) + second_page = source_api.list_jobs( + from_date=DEMO_FROM_DATE, + to_date=DEMO_TO_DATE, + cursor=first_page.next_cursor or 0, + ) + third_page = source_api.list_jobs( + from_date=DEMO_FROM_DATE, + to_date=DEMO_TO_DATE, + cursor=second_page.next_cursor or 0, + ) + + assert first_page.next_cursor == 4 + assert second_page.next_cursor == 8 + assert third_page.next_cursor is None + assert [job.source_row_id for job in first_page.items] == [ + "row-001", + "row-002", + "row-003", + "row-004", + ] + assert [job.source_row_id for job in second_page.items] == [ + "row-005", + "row-006", + "row-007", + "row-008", + ] + assert [job.source_row_id for job in third_page.items] == ["row-009"] + + +@pytest.mark.parametrize( + ("page_size", "cursor", "message"), + [ + (0, 0, "page_size must be greater than zero"), + (1, -1, "cursor must be greater than or equal to zero"), + ], +) +def test_list_jobs_rejects_invalid_pagination_inputs( + page_size: int, + cursor: int, + message: str, +) -> None: + source_api = FixtureSourceAPI() + + with pytest.raises(ValueError, match=message): + source_api.list_jobs( + from_date=DEMO_FROM_DATE, + to_date=DEMO_TO_DATE, + page_size=page_size, + cursor=cursor, + ) + + +def test_list_jobs_rejects_reversed_date_range() -> None: + source_api = FixtureSourceAPI() + + with pytest.raises(ValueError, match="from_date must be before or equal to to_date"): + source_api.list_jobs(from_date=DEMO_TO_DATE, to_date=DEMO_FROM_DATE) + + +def test_list_job_updates_returns_only_requested_job_ids() -> None: + source_api = FixtureSourceAPI() + + updates = source_api.list_job_updates(["JOB-1001", "JOB-1002"]) + + assert [update.update_id for update in updates] == [ + "update-001", + "update-002", + "update-003", + ] + assert {update.job_id for update in updates} == {"JOB-1001", "JOB-1002"} + + +def test_load_source_jobs_rejects_non_array_fixture(tmp_path: Path) -> None: + (tmp_path / "source_jobs.json").write_text("{}", encoding="utf-8") + + with pytest.raises(ValueError, match="must contain a JSON array"): + load_source_jobs(tmp_path) + + +def test_load_source_jobs_rejects_non_object_entries(tmp_path: Path) -> None: + (tmp_path / "source_jobs.json").write_text("[1]", encoding="utf-8") + + with pytest.raises(ValueError, match="must contain JSON objects"): + load_source_jobs(tmp_path) + + +def test_list_jobs_rejects_records_without_source_updated_at(tmp_path: Path) -> None: + fixture_record = { + "source_row_id": "row-missing-updated-at", + "job_id": "JOB-MISSING-DATE", + "customer_name": "Missing Date Co", + "status": "scheduled", + "work_type": "repair", + "scheduled_date": "2026-06-02", + "updated_at": None, + } + (tmp_path / "source_jobs.json").write_text(json.dumps([fixture_record]), encoding="utf-8") + source_api = FixtureSourceAPI(RuntimeSettings(sample_data_dir=tmp_path)) + + with pytest.raises(ValueError, match="source job row-missing-updated-at is missing updated_at"): + source_api.list_jobs(from_date=DEMO_FROM_DATE, to_date=DEMO_TO_DATE)