Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ on:
branches:
- main
- project-foundation
- source-fixture-api

jobs:
verify:
Expand Down Expand Up @@ -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
Expand All @@ -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
58 changes: 58 additions & 0 deletions sample_data/source_job_updates.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
101 changes: 101 additions & 0 deletions sample_data/source_jobs.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
105 changes: 105 additions & 0 deletions src/clean_data_export_api/source_api.py
Original file line number Diff line number Diff line change
@@ -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()
77 changes: 77 additions & 0 deletions tests/test_sample_data.py
Original file line number Diff line number Diff line change
@@ -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()
Loading