From 048169b1b6a07ccf2c3652871ea8a423e0d4daec Mon Sep 17 00:00:00 2001 From: jaeyeopme Date: Sun, 7 Jun 2026 01:43:04 +0900 Subject: [PATCH 1/5] test(demo): add generated export outputs Add the checked-in demo export package for the implemented FieldOps date range. Tested: uv run clean-data-export-api export jobs --from-date 2026-06-01 --to-date 2026-06-05 --output-dir outputs --database-path outputs/run_history.sqlite3 --page-size 4 --- outputs/clean_jobs.csv | 4 ++++ outputs/clean_jobs.json | 29 +++++++++++++++++++++++++++++ outputs/rejected_jobs.csv | 7 +++++++ outputs/run_summary.md | 27 +++++++++++++++++++++++++++ 4 files changed, 67 insertions(+) create mode 100644 outputs/clean_jobs.csv create mode 100644 outputs/clean_jobs.json create mode 100644 outputs/rejected_jobs.csv create mode 100644 outputs/run_summary.md diff --git a/outputs/clean_jobs.csv b/outputs/clean_jobs.csv new file mode 100644 index 0000000..c312376 --- /dev/null +++ b/outputs/clean_jobs.csv @@ -0,0 +1,4 @@ +job_id,customer_name,job_status,work_type,scheduled_date,last_updated_at,source_updated_at +JOB-1001,Avery Plumbing,scheduled,repair,2026-06-02,2026-06-02T08:45:00Z,2026-06-01T09:15:00Z +JOB-1002,Northstar Dental,in_progress,installation,2026-06-03,2026-06-03T10:15:00Z,2026-06-02T10:30:00Z +JOB-1003,Harbor Cafe,completed,inspection,2026-06-04,2026-06-04T16:30:00Z,2026-06-03T11:45:00Z diff --git a/outputs/clean_jobs.json b/outputs/clean_jobs.json new file mode 100644 index 0000000..1f9a2bd --- /dev/null +++ b/outputs/clean_jobs.json @@ -0,0 +1,29 @@ +[ + { + "job_id": "JOB-1001", + "customer_name": "Avery Plumbing", + "job_status": "scheduled", + "work_type": "repair", + "scheduled_date": "2026-06-02", + "last_updated_at": "2026-06-02T08:45:00Z", + "source_updated_at": "2026-06-01T09:15:00Z" + }, + { + "job_id": "JOB-1002", + "customer_name": "Northstar Dental", + "job_status": "in_progress", + "work_type": "installation", + "scheduled_date": "2026-06-03", + "last_updated_at": "2026-06-03T10:15:00Z", + "source_updated_at": "2026-06-02T10:30:00Z" + }, + { + "job_id": "JOB-1003", + "customer_name": "Harbor Cafe", + "job_status": "completed", + "work_type": "inspection", + "scheduled_date": "2026-06-04", + "last_updated_at": "2026-06-04T16:30:00Z", + "source_updated_at": "2026-06-03T11:45:00Z" + } +] diff --git a/outputs/rejected_jobs.csv b/outputs/rejected_jobs.csv new file mode 100644 index 0000000..d77de3f --- /dev/null +++ b/outputs/rejected_jobs.csv @@ -0,0 +1,7 @@ +source_row_id,source_job_id,reason_code,reason_detail,raw_record +row-004,,missing_job_id,job_id is required,"{""customer_name"": ""Brightline Salon"", ""job_id"": null, ""scheduled_date"": ""2026-06-04"", ""source_row_id"": ""row-004"", ""status"": ""scheduled"", ""updated_at"": ""2026-06-03T13:00:00Z"", ""work_type"": ""repair""}" +row-005,JOB-1005,missing_customer_name,customer_name is required,"{""customer_name"": """", ""job_id"": ""JOB-1005"", ""scheduled_date"": ""2026-06-05"", ""source_row_id"": ""row-005"", ""status"": ""scheduled"", ""updated_at"": ""2026-06-04T08:20:00Z"", ""work_type"": ""maintenance""}" +row-006,JOB-1006,invalid_status,job_status must be an allowed value,"{""customer_name"": ""Cedar Books"", ""job_id"": ""JOB-1006"", ""scheduled_date"": ""2026-06-05"", ""source_row_id"": ""row-006"", ""status"": ""waiting"", ""updated_at"": ""2026-06-04T09:10:00Z"", ""work_type"": ""repair""}" +row-007,JOB-1007,invalid_date,scheduled_date must be a valid ISO date,"{""customer_name"": ""Pioneer Fitness"", ""job_id"": ""JOB-1007"", ""scheduled_date"": ""2026-15-01"", ""source_row_id"": ""row-007"", ""status"": ""scheduled"", ""updated_at"": ""2026-06-04T14:30:00Z"", ""work_type"": ""inspection""}" +row-008,JOB-1008,missing_work_type,work_type is required,"{""customer_name"": ""Metro Florist"", ""job_id"": ""JOB-1008"", ""scheduled_date"": ""2026-06-05"", ""source_row_id"": ""row-008"", ""status"": ""scheduled"", ""updated_at"": ""2026-06-05T08:00:00Z"", ""work_type"": null}" +row-009,JOB-1002,duplicate_record,duplicate job_id JOB-1002 was skipped,"{""customer_name"": ""Northstar Dental"", ""job_id"": ""JOB-1002"", ""scheduled_date"": ""2026-06-03"", ""source_row_id"": ""row-009"", ""status"": ""in_progress"", ""updated_at"": ""2026-06-05T12:00:00Z"", ""work_type"": ""installation""}" diff --git a/outputs/run_summary.md b/outputs/run_summary.md new file mode 100644 index 0000000..00745f4 --- /dev/null +++ b/outputs/run_summary.md @@ -0,0 +1,27 @@ +# Export Run Summary + +Run ID: 1 +Requested date range: 2026-06-01 to 2026-06-05 +Source records fetched: 9 +Clean records written: 3 +Duplicates removed: 1 +Rejected records written: 6 + +## Output File Paths + +- Clean CSV: `outputs/clean_jobs.csv` +- Clean JSON: `outputs/clean_jobs.json` +- Rejected CSV: `outputs/rejected_jobs.csv` +- Run summary: `outputs/run_summary.md` + +## Known Limits + +- Uses fictional local fixture data only. +- Covers jobs and job_updates only. +- Does not connect to a real vendor API. + +## Suggested Next Steps + +- Review rejected rows before using the clean export. +- Add buyer-specific validation rules before a real integration. +- Review vendor API docs before replacing the fixture source. From 75ce0870b34ce075fa8bbf81ea743f9f8c495163 Mon Sep 17 00:00:00 2001 From: jaeyeopme Date: Sun, 7 Jun 2026 01:43:10 +0900 Subject: [PATCH 2/5] docs(delivery): sync implemented mvp contract Update public documentation from planned status to the implemented local workflow, including current counts, output files, and API request boundaries. Tested: uv run pytest tests/test_demo_contract.py tests/test_docs_contract.py -v Tested: rg "Planned|planned|not started|future CLI|future API|production-ready|enterprise security|guaranteed revenue|guaranteed ROI|output_dir" README.md docs --- README.md | 49 ++++++++++++++++++++++++++++++-------------- docs/ARCHITECTURE.md | 46 +++++++++++++++++++++++------------------ docs/DELIVERY.md | 18 ++++++++-------- docs/PRD.md | 10 ++++++--- 4 files changed, 76 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 4a80e68..a5eb649 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,33 @@ # Clean Data Export API -Clean Data Export API is a planned local workflow for turning imperfect job -records into review-ready CSV and JSON exports, with duplicate handling, -rejected rows, run history, and handoff notes. +Clean Data Export API is a local workflow for turning imperfect job records into +review-ready CSV and JSON exports, with duplicate handling, rejected rows, run +history, and handoff notes. The workflow is intentionally local and uses fictional data. It models an operations tool called **FieldOps Desk** and focuses on exporting `jobs` and related `job_updates` records. It does not connect to a real customer system or use real credentials. -## What the workflow will deliver +## Current Status -This is not a broad backend platform. The planned workflow focuses on one -handoff-ready export package: +The first local MVP is implemented. The repository includes deterministic +FieldOps Desk fixture data, a reusable export service, FastAPI and CLI entry +points, clean CSV/JSON outputs, rejected rows, SQLite run history, and tests. + +The demo export for `2026-06-01` through `2026-06-05` returns: + +```text +source_count=9 +clean_count=3 +duplicate_count=1 +rejected_count=6 +``` + +## What the workflow delivers + +This is not a broad backend platform. The workflow focuses on one handoff-ready +export package: - pull a limited set of source records. - map source fields into a buyer-friendly output shape. @@ -35,7 +50,7 @@ They need a repeatable local workflow that produces: - a rejected-row file explaining records that were not accepted. - a short run summary with counts and file paths. -## Planned workflow +## Workflow ```text source fixture @@ -52,7 +67,7 @@ source fixture The same export service will be used by both the FastAPI endpoint and the CLI, so both entry points produce the same results. -## Planned outputs +## Outputs ```text outputs/ @@ -67,7 +82,7 @@ the same accepted records in JSON form. `rejected_jobs.csv` keeps invalid or duplicate records visible with reason codes. `run_summary.md` gives a human-readable handoff summary. -## Planned project structure +## Project Structure ```text clean-data-export-api/ @@ -101,16 +116,16 @@ clean-data-export-api/ tests/ ``` -## Planned usage +## Usage -The intended future CLI flow is: +Run the CLI export: ```bash uv sync uv run clean-data-export-api export jobs --from-date 2026-06-01 --to-date 2026-06-05 ``` -The intended future API flow is: +Serve the local API: ```bash uv run clean-data-export-api serve @@ -126,6 +141,10 @@ Content-Type: application/json } ``` +The API uses the configured local output directory. The CLI also supports +explicit `--output-dir`, `--database-path`, and `--sample-data-dir` options for +local runs. + ## Documentation - [Product requirements](docs/PRD.md) @@ -139,6 +158,6 @@ This project does not use real third-party credentials, scraping, login bypass, paid APIs, or real customer data. All sample data should be fictional and safe to share. -This project should not claim production readiness, guaranteed business -outcomes, enterprise security, or support for a real vendor API before that API -has been reviewed. +This project should not claim readiness for live operations, guaranteed business +outcomes, advanced security guarantees, or support for a real vendor API before +that API has been reviewed. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 148e188..7129fd0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,9 +2,9 @@ ## Purpose -This document describes the planned technical design for Clean Data Export API. -The design keeps the workflow small, local, deterministic, and reusable from -both FastAPI and the CLI. +This document describes the technical design for Clean Data Export API. The +design keeps the workflow small, local, deterministic, and reusable from both +FastAPI and the CLI. ## System overview @@ -31,7 +31,7 @@ inputs, call the service, and return the summary. - Keep output files easy to inspect in a spreadsheet. - Avoid abstractions that are not needed for the first export type. -## Planned package layout +## Package Layout ```text src/clean_data_export_api/ @@ -90,13 +90,15 @@ Expected settings: Defines typed data models for the workflow. -Expected models: +Models include: - source job. - source job update. - clean job. - rejected row. +- API job export request. - export request. +- export run record. - export summary. - output paths. @@ -153,9 +155,11 @@ Workflow: 4. Map source records into clean candidates. 5. Validate candidates. 6. Remove duplicates from accepted records. -7. Write output files. -8. Persist run history. -9. Return summary. +7. Build the output paths and run record. +8. Insert run history to get a run id. +9. Write output files. +10. Remove the run history row if report writing fails. +11. Return summary. This module coordinates the workflow but should delegate specialized logic to mapping, validation, reports, and repository modules. @@ -189,7 +193,7 @@ SQLite is not used as the source of truth for job data. ### Source job -Planned source fields: +Source fields: - `source_row_id` - `job_id` @@ -201,7 +205,7 @@ Planned source fields: ### Source job update -Planned source fields: +Source fields: - `update_id` - `job_id` @@ -263,11 +267,13 @@ Request: ```json { "from_date": "2026-06-01", - "to_date": "2026-06-05", - "output_dir": "outputs" + "to_date": "2026-06-05" } ``` +The API uses the configured local output directory. Caller-controlled output +paths are rejected by the request model. + Response: ```json @@ -275,10 +281,10 @@ Response: "run_id": 1, "from_date": "2026-06-01", "to_date": "2026-06-05", - "source_count": 12, - "clean_count": 8, - "duplicate_count": 2, - "rejected_count": 2, + "source_count": 9, + "clean_count": 3, + "duplicate_count": 1, + "rejected_count": 6, "output_paths": { "clean_csv": "outputs/clean_jobs.csv", "clean_json": "outputs/clean_jobs.json", @@ -288,9 +294,6 @@ Response: } ``` -Counts above are examples. Implementation and tests should define deterministic -fixture counts. - ## CLI contract Command: @@ -309,9 +312,12 @@ The CLI should print: - rejected count. - output file paths. +The CLI supports local path overrides such as `--output-dir`, +`--database-path`, and `--sample-data-dir`. + ## SQLite run history -Planned table: `export_runs` +Table: `export_runs` Fields: diff --git a/docs/DELIVERY.md b/docs/DELIVERY.md index b57f1b1..6ef1579 100644 --- a/docs/DELIVERY.md +++ b/docs/DELIVERY.md @@ -2,15 +2,15 @@ ## Purpose -This document defines the expected handoff package for the finished delivery. +This document defines the handoff package for the finished delivery. -The delivery should show more than working code. It should show how a buyer -would receive a clean export workflow: output files, rejected rows, a run -summary, and enough notes to rerun or extend the work. +The delivery shows more than working code. It shows how a buyer would receive a +clean export workflow: output files, rejected rows, a run summary, and enough +notes to rerun or extend the work. ## Delivery package -The finished repository should include: +The repository includes: ```text README.md @@ -94,7 +94,7 @@ Required sections: ## Buyer-facing handoff standard -The finished delivery should answer these questions without requiring a meeting: +The delivery should answer these questions without requiring a meeting: - What does this workflow do? - What source data does it read? @@ -146,9 +146,9 @@ Realistic follow-up work may include: Avoid promising: -- guaranteed revenue or ROI. -- production readiness. -- enterprise security. +- revenue or ROI guarantees. +- readiness for live operations. +- advanced security guarantees. - support for a real vendor before reviewing its API docs and credentials. - full platform ownership. diff --git a/docs/PRD.md b/docs/PRD.md index f4743ea..252ca7d 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -162,7 +162,9 @@ The CLI must support: clean-data-export-api export jobs --from-date 2026-06-01 --to-date 2026-06-05 ``` -The CLI response must include the same summary fields as the API response. +The CLI response must include the same summary fields as the API response. It +may accept local path overrides such as `--output-dir`, `--database-path`, and +`--sample-data-dir`. ### API @@ -176,7 +178,9 @@ Request fields: - `from_date` - `to_date` -- optional `output_dir` + +The API uses the configured local output directory. It does not accept caller +controlled output paths. Response fields: @@ -199,7 +203,7 @@ Response fields: - No hosted production deployment. - No background scheduler in the first version. - No broad dashboard application. -- No claim that this project is production-ready. +- No claim that this project is ready for live operations. ## Acceptance criteria From 5ba0cf9e8a86c0208f9c6657af20c70954d830cd Mon Sep 17 00:00:00 2001 From: jaeyeopme Date: Sun, 7 Jun 2026 01:43:18 +0900 Subject: [PATCH 3/5] test(contract): lock demo and docs contracts Add regression coverage for demo counts, output columns, rejected reason codes, current README commands, and public documentation boundaries. Tested: uv run pytest tests/test_demo_contract.py tests/test_docs_contract.py -v Tested: uv run pytest -v Tested: uv run pytest --cov=clean_data_export_api --cov-report=term-missing --cov-fail-under=90 --- tests/test_demo_contract.py | 51 ++++++++++++++++++++++++++++ tests/test_docs_contract.py | 66 +++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 tests/test_demo_contract.py create mode 100644 tests/test_docs_contract.py diff --git a/tests/test_demo_contract.py b/tests/test_demo_contract.py new file mode 100644 index 0000000..d11d887 --- /dev/null +++ b/tests/test_demo_contract.py @@ -0,0 +1,51 @@ +import csv +from datetime import date +from pathlib import Path + +from clean_data_export_api.config import RuntimeSettings +from clean_data_export_api.export_service import run_job_export +from clean_data_export_api.models import ExportRequest + +EXPECTED_CLEAN_COLUMNS = [ + "job_id", + "customer_name", + "job_status", + "work_type", + "scheduled_date", + "last_updated_at", + "source_updated_at", +] +EXPECTED_REJECTED_REASON_CODES = { + "missing_job_id", + "missing_customer_name", + "missing_work_type", + "invalid_status", + "invalid_date", + "duplicate_record", +} + + +def test_demo_contract_counts_columns_and_reason_codes(tmp_path) -> None: + summary = run_job_export( + ExportRequest(from_date=date(2026, 6, 1), to_date=date(2026, 6, 5)), + settings=RuntimeSettings( + sample_data_dir=Path("sample_data"), + output_dir=tmp_path / "outputs", + database_path=tmp_path / "history.sqlite3", + page_size=4, + ), + ) + + assert summary.source_count == 9 + assert summary.clean_count == 3 + assert summary.duplicate_count == 1 + assert summary.rejected_count == 6 + + with summary.output_paths.clean_csv.open(newline="", encoding="utf-8") as file: + clean_rows = list(csv.DictReader(file)) + assert clean_rows + assert list(clean_rows[0]) == EXPECTED_CLEAN_COLUMNS + + with summary.output_paths.rejected_csv.open(newline="", encoding="utf-8") as file: + rejected_rows = list(csv.DictReader(file)) + assert {row["reason_code"] for row in rejected_rows} == EXPECTED_REJECTED_REASON_CODES diff --git a/tests/test_docs_contract.py b/tests/test_docs_contract.py new file mode 100644 index 0000000..b4d50b2 --- /dev/null +++ b/tests/test_docs_contract.py @@ -0,0 +1,66 @@ +from pathlib import Path + +DOC_PATHS = ( + Path("README.md"), + Path("docs/PRD.md"), + Path("docs/ARCHITECTURE.md"), + Path("docs/DELIVERY.md"), +) + + +def test_docs_include_current_demo_contract() -> None: + docs = _read_docs() + + for expected in ( + "outputs/clean_jobs.csv", + "outputs/clean_jobs.json", + "outputs/rejected_jobs.csv", + "outputs/run_summary.md", + "missing_job_id", + "missing_customer_name", + "missing_work_type", + "invalid_status", + "invalid_date", + "duplicate_record", + "source_count", + "clean_count", + "duplicate_count", + "rejected_count", + "source_count=9", + "clean_count=3", + "duplicate_count=1", + "rejected_count=6", + ): + assert expected in docs + + +def test_readme_uses_current_demo_commands_and_status() -> None: + readme = Path("README.md").read_text(encoding="utf-8") + + assert "uv sync" in readme + assert "uv run clean-data-export-api export jobs" in readme + assert "uv run clean-data-export-api serve" in readme + assert "The first local MVP is implemented." in readme + assert "Implementation has not " + "started yet" not in readme + assert "intended future " + "CLI" not in readme + assert "intended future " + "API" not in readme + + +def test_docs_keep_scope_boundaries_visible() -> None: + docs = _read_docs() + + for forbidden_claim in ( + "production" + "-ready", + "enterprise" + " security", + "guaranteed" + " revenue", + "guaranteed" + " ROI", + ): + assert forbidden_claim not in docs + + assert "No real third-party credentials" in docs + assert "No scraping" in docs + assert "No AI" in docs or "No AI or LLM analysis" in docs + + +def _read_docs() -> str: + return "\n".join(path.read_text(encoding="utf-8") for path in DOC_PATHS) From 33af9f3502dcbe08e48999d21480ba316d60b9f7 Mon Sep 17 00:00:00 2001 From: jaeyeopme Date: Sun, 7 Jun 2026 01:56:12 +0900 Subject: [PATCH 4/5] fix(contract): make demo outputs reproducible Set CSV report line endings explicitly and lock checked-in demo outputs against fresh generated files. Also tighten current-status wording and public documentation wording guards. Tested: uv run pytest tests/test_demo_contract.py tests/test_docs_contract.py -v Tested: uv run ruff check . Tested: uv run ruff format --check . Tested: uv run basedpyright Tested: uv run pytest -v Tested: uv run pytest --cov=clean_data_export_api --cov-report=term-missing --cov-fail-under=90 Tested: uv run python -m compileall src tests --- README.md | 4 ++-- src/clean_data_export_api/reports.py | 13 +++++++++++-- tests/test_demo_contract.py | 26 ++++++++++++++++++++++++++ tests/test_docs_contract.py | 20 ++++++++++++++++++++ 4 files changed, 59 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a5eb649..7d96e9c 100644 --- a/README.md +++ b/README.md @@ -64,8 +64,8 @@ source fixture -> API/CLI response ``` -The same export service will be used by both the FastAPI endpoint and the CLI, -so both entry points produce the same results. +The FastAPI endpoint and CLI use the same export service, so both entry points +produce the same results. ## Outputs diff --git a/src/clean_data_export_api/reports.py b/src/clean_data_export_api/reports.py index 432d9fa..57e6ebe 100644 --- a/src/clean_data_export_api/reports.py +++ b/src/clean_data_export_api/reports.py @@ -29,6 +29,7 @@ ) _CLEAN_JOBS_ADAPTER = TypeAdapter(list[CleanJob]) +_CSV_LINE_TERMINATOR = "\n" def output_paths_for(output_dir: Path) -> OutputPaths: @@ -62,7 +63,11 @@ def write_reports( def _write_clean_csv(path: Path, clean_jobs: Iterable[CleanJob]) -> None: with path.open("w", newline="", encoding="utf-8") as file: - writer = csv.DictWriter(file, fieldnames=CLEAN_COLUMNS) + writer = csv.DictWriter( + file, + fieldnames=CLEAN_COLUMNS, + lineterminator=_CSV_LINE_TERMINATOR, + ) writer.writeheader() for job in clean_jobs: writer.writerow(_clean_job_row(job)) @@ -75,7 +80,11 @@ def _write_clean_json(path: Path, clean_jobs: Iterable[CleanJob]) -> None: def _write_rejected_csv(path: Path, rejected_rows: Iterable[RejectedRow]) -> None: with path.open("w", newline="", encoding="utf-8") as file: - writer = csv.DictWriter(file, fieldnames=REJECTED_COLUMNS) + writer = csv.DictWriter( + file, + fieldnames=REJECTED_COLUMNS, + lineterminator=_CSV_LINE_TERMINATOR, + ) writer.writeheader() for row in rejected_rows: writer.writerow(_rejected_row(row)) diff --git a/tests/test_demo_contract.py b/tests/test_demo_contract.py index d11d887..1acc3a3 100644 --- a/tests/test_demo_contract.py +++ b/tests/test_demo_contract.py @@ -23,6 +23,13 @@ "invalid_date", "duplicate_record", } +OUTPUT_FILENAMES = ( + "clean_jobs.csv", + "clean_jobs.json", + "rejected_jobs.csv", + "run_summary.md", +) +PROJECT_ROOT = Path(__file__).resolve().parents[1] def test_demo_contract_counts_columns_and_reason_codes(tmp_path) -> None: @@ -49,3 +56,22 @@ def test_demo_contract_counts_columns_and_reason_codes(tmp_path) -> None: with summary.output_paths.rejected_csv.open(newline="", encoding="utf-8") as file: rejected_rows = list(csv.DictReader(file)) assert {row["reason_code"] for row in rejected_rows} == EXPECTED_REJECTED_REASON_CODES + + +def test_checked_in_demo_outputs_match_generated_files(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + + run_job_export( + ExportRequest(from_date=date(2026, 6, 1), to_date=date(2026, 6, 5)), + settings=RuntimeSettings( + sample_data_dir=PROJECT_ROOT / "sample_data", + output_dir=Path("outputs"), + database_path=tmp_path / "history.sqlite3", + page_size=4, + ), + ) + + for filename in OUTPUT_FILENAMES: + assert (tmp_path / "outputs" / filename).read_bytes() == ( + PROJECT_ROOT / "outputs" / filename + ).read_bytes() diff --git a/tests/test_docs_contract.py b/tests/test_docs_contract.py index b4d50b2..1d43dee 100644 --- a/tests/test_docs_contract.py +++ b/tests/test_docs_contract.py @@ -6,6 +6,19 @@ Path("docs/ARCHITECTURE.md"), Path("docs/DELIVERY.md"), ) +PUBLIC_FORBIDDEN_TERMS = ( + "Co" + "dex", + "AG" + "ENTS", + ".ag" + "ents", + ".co" + "dex", + "AI " + "generated", + "generated " + "by", + "port" + "folio", + "Fi" + "verr", + "technical " + "proof", + "show" + "case", + "AI " + "slop", +) def test_docs_include_current_demo_contract() -> None: @@ -62,5 +75,12 @@ def test_docs_keep_scope_boundaries_visible() -> None: assert "No AI" in docs or "No AI or LLM analysis" in docs +def test_docs_do_not_expose_internal_workflow_terms() -> None: + docs = _read_docs() + + for forbidden_term in PUBLIC_FORBIDDEN_TERMS: + assert forbidden_term not in docs + + def _read_docs() -> str: return "\n".join(path.read_text(encoding="utf-8") for path in DOC_PATHS) From 40d503ddf759b03b64ce24b99169dfa747563dcc Mon Sep 17 00:00:00 2001 From: jaeyeopme Date: Sun, 7 Jun 2026 02:03:28 +0900 Subject: [PATCH 5/5] test(contract): simplify contract coverage Remove brittle documentation text assertions and keep the checked-in demo artifact contract test as the regression guard. Extract a shared job export candidate factory so validation and duplicate handling tests do not carry copy-pasted fixtures. Tested: - uv run ruff check . - uv run ruff format --check . - uv run basedpyright - uv run pytest -v - uv run pytest --cov=clean_data_export_api --cov-report=term-missing --cov-fail-under=90 - uv run python -m compileall src tests - git diff --check --- tests/__init__.py | 1 + tests/factories.py | 25 ++++++++++ tests/test_docs_contract.py | 86 -------------------------------- tests/test_duplicate_handling.py | 62 +++++++++-------------- tests/test_validation.py | 35 +++---------- 5 files changed, 55 insertions(+), 154 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/factories.py delete mode 100644 tests/test_docs_contract.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/factories.py b/tests/factories.py new file mode 100644 index 0000000..09d618f --- /dev/null +++ b/tests/factories.py @@ -0,0 +1,25 @@ +from datetime import UTC, datetime + +from clean_data_export_api.mapping import JobExportCandidate + + +def job_export_candidate( + *, + source_row_id: str = "row-001", + job_id: str | None = "JOB-1001", + customer_name: str | None = "Avery Plumbing", + job_status: str | None = "scheduled", + work_type: str | None = "repair", + scheduled_date: str | None = "2026-06-02", +) -> JobExportCandidate: + return JobExportCandidate( + source_row_id=source_row_id, + job_id=job_id, + customer_name=customer_name, + job_status=job_status, + work_type=work_type, + scheduled_date=scheduled_date, + source_updated_at=datetime(2026, 6, 1, 9, 15, tzinfo=UTC), + last_updated_at=datetime(2026, 6, 2, 8, 45, tzinfo=UTC), + raw_record={"source_row_id": source_row_id, "job_id": job_id}, + ) diff --git a/tests/test_docs_contract.py b/tests/test_docs_contract.py deleted file mode 100644 index 1d43dee..0000000 --- a/tests/test_docs_contract.py +++ /dev/null @@ -1,86 +0,0 @@ -from pathlib import Path - -DOC_PATHS = ( - Path("README.md"), - Path("docs/PRD.md"), - Path("docs/ARCHITECTURE.md"), - Path("docs/DELIVERY.md"), -) -PUBLIC_FORBIDDEN_TERMS = ( - "Co" + "dex", - "AG" + "ENTS", - ".ag" + "ents", - ".co" + "dex", - "AI " + "generated", - "generated " + "by", - "port" + "folio", - "Fi" + "verr", - "technical " + "proof", - "show" + "case", - "AI " + "slop", -) - - -def test_docs_include_current_demo_contract() -> None: - docs = _read_docs() - - for expected in ( - "outputs/clean_jobs.csv", - "outputs/clean_jobs.json", - "outputs/rejected_jobs.csv", - "outputs/run_summary.md", - "missing_job_id", - "missing_customer_name", - "missing_work_type", - "invalid_status", - "invalid_date", - "duplicate_record", - "source_count", - "clean_count", - "duplicate_count", - "rejected_count", - "source_count=9", - "clean_count=3", - "duplicate_count=1", - "rejected_count=6", - ): - assert expected in docs - - -def test_readme_uses_current_demo_commands_and_status() -> None: - readme = Path("README.md").read_text(encoding="utf-8") - - assert "uv sync" in readme - assert "uv run clean-data-export-api export jobs" in readme - assert "uv run clean-data-export-api serve" in readme - assert "The first local MVP is implemented." in readme - assert "Implementation has not " + "started yet" not in readme - assert "intended future " + "CLI" not in readme - assert "intended future " + "API" not in readme - - -def test_docs_keep_scope_boundaries_visible() -> None: - docs = _read_docs() - - for forbidden_claim in ( - "production" + "-ready", - "enterprise" + " security", - "guaranteed" + " revenue", - "guaranteed" + " ROI", - ): - assert forbidden_claim not in docs - - assert "No real third-party credentials" in docs - assert "No scraping" in docs - assert "No AI" in docs or "No AI or LLM analysis" in docs - - -def test_docs_do_not_expose_internal_workflow_terms() -> None: - docs = _read_docs() - - for forbidden_term in PUBLIC_FORBIDDEN_TERMS: - assert forbidden_term not in docs - - -def _read_docs() -> str: - return "\n".join(path.read_text(encoding="utf-8") for path in DOC_PATHS) diff --git a/tests/test_duplicate_handling.py b/tests/test_duplicate_handling.py index 5809788..3e71421 100644 --- a/tests/test_duplicate_handling.py +++ b/tests/test_duplicate_handling.py @@ -1,18 +1,16 @@ -from datetime import UTC, datetime - -from clean_data_export_api.mapping import JobExportCandidate from clean_data_export_api.validation import ( partition_valid_jobs, remove_duplicate_jobs, ) +from tests.factories import job_export_candidate def test_remove_duplicate_jobs_keeps_first_valid_job_id() -> None: valid_jobs, validation_rejects = partition_valid_jobs( [ - _candidate(source_row_id="row-001", job_id="JOB-1001"), - _candidate(source_row_id="row-002", job_id="JOB-1002"), - _candidate(source_row_id="row-003", job_id="JOB-1001"), + job_export_candidate(source_row_id="row-001", job_id="JOB-1001"), + job_export_candidate(source_row_id="row-002", job_id="JOB-1002"), + job_export_candidate(source_row_id="row-003", job_id="JOB-1001"), ] ) @@ -30,8 +28,12 @@ def test_remove_duplicate_jobs_keeps_first_valid_job_id() -> None: def test_invalid_rows_are_rejected_before_duplicate_handling() -> None: valid_jobs, validation_rejects = partition_valid_jobs( [ - _candidate(source_row_id="row-001", job_id="JOB-1001", job_status="waiting"), - _candidate(source_row_id="row-002", job_id="JOB-1001"), + job_export_candidate( + source_row_id="row-001", + job_id="JOB-1001", + job_status="waiting", + ), + job_export_candidate(source_row_id="row-002", job_id="JOB-1001"), ] ) @@ -44,15 +46,19 @@ def test_invalid_rows_are_rejected_before_duplicate_handling() -> None: def test_full_fixture_rule_counts_match_mvp_contract() -> None: candidates = [ - _candidate(source_row_id="row-001", job_id="JOB-1001"), - _candidate(source_row_id="row-002", job_id="JOB-1002"), - _candidate(source_row_id="row-003", job_id="JOB-1003"), - _candidate(source_row_id="row-004", job_id=None), - _candidate(source_row_id="row-005", job_id="JOB-1005", customer_name=""), - _candidate(source_row_id="row-006", job_id="JOB-1006", job_status="waiting"), - _candidate(source_row_id="row-007", job_id="JOB-1007", scheduled_date="2026-15-01"), - _candidate(source_row_id="row-008", job_id="JOB-1008", work_type=None), - _candidate(source_row_id="row-009", job_id="JOB-1002"), + job_export_candidate(source_row_id="row-001", job_id="JOB-1001"), + job_export_candidate(source_row_id="row-002", job_id="JOB-1002"), + job_export_candidate(source_row_id="row-003", job_id="JOB-1003"), + job_export_candidate(source_row_id="row-004", job_id=None), + job_export_candidate(source_row_id="row-005", job_id="JOB-1005", customer_name=""), + job_export_candidate(source_row_id="row-006", job_id="JOB-1006", job_status="waiting"), + job_export_candidate( + source_row_id="row-007", + job_id="JOB-1007", + scheduled_date="2026-15-01", + ), + job_export_candidate(source_row_id="row-008", job_id="JOB-1008", work_type=None), + job_export_candidate(source_row_id="row-009", job_id="JOB-1002"), ] valid_jobs, validation_rejects = partition_valid_jobs(candidates) @@ -69,25 +75,3 @@ def test_full_fixture_rule_counts_match_mvp_contract() -> None: "invalid_date", "missing_work_type", ] - - -def _candidate( - *, - source_row_id: str, - job_id: str | None, - customer_name: str | None = "Avery Plumbing", - job_status: str | None = "scheduled", - work_type: str | None = "repair", - scheduled_date: str | None = "2026-06-02", -) -> JobExportCandidate: - return JobExportCandidate( - source_row_id=source_row_id, - job_id=job_id, - customer_name=customer_name, - job_status=job_status, - work_type=work_type, - scheduled_date=scheduled_date, - source_updated_at=datetime(2026, 6, 1, 9, 15, tzinfo=UTC), - last_updated_at=datetime(2026, 6, 2, 8, 45, tzinfo=UTC), - raw_record={"source_row_id": source_row_id, "job_id": job_id}, - ) diff --git a/tests/test_validation.py b/tests/test_validation.py index d702cc2..7d2da08 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -1,15 +1,14 @@ -from datetime import UTC, datetime from typing import TypedDict import pytest -from clean_data_export_api.mapping import JobExportCandidate from clean_data_export_api.models import RejectedRow from clean_data_export_api.validation import ( ValidatedJob, partition_valid_jobs, validate_job_candidate, ) +from tests.factories import job_export_candidate class CandidateOverrides(TypedDict, total=False): @@ -21,7 +20,7 @@ class CandidateOverrides(TypedDict, total=False): def test_validate_job_candidate_accepts_valid_candidate() -> None: - result = validate_job_candidate(_candidate()) + result = validate_job_candidate(job_export_candidate()) assert isinstance(result, ValidatedJob) assert result.source_row_id == "row-001" @@ -51,7 +50,7 @@ def test_validate_job_candidate_rejects_invalid_candidate( candidate_kwargs: CandidateOverrides, reason_code: str, ) -> None: - candidate = _candidate(**candidate_kwargs) + candidate = job_export_candidate(**candidate_kwargs) result = validate_job_candidate(candidate) @@ -63,9 +62,9 @@ def test_validate_job_candidate_rejects_invalid_candidate( def test_partition_valid_jobs_preserves_order_and_rejections() -> None: - valid_candidate = _candidate(source_row_id="row-001", job_id="JOB-1001") - invalid_candidate = _candidate(source_row_id="row-002", job_id=None) - another_valid_candidate = _candidate(source_row_id="row-003", job_id="JOB-1003") + valid_candidate = job_export_candidate(source_row_id="row-001", job_id="JOB-1001") + invalid_candidate = job_export_candidate(source_row_id="row-002", job_id=None) + another_valid_candidate = job_export_candidate(source_row_id="row-003", job_id="JOB-1003") valid_jobs, rejected_rows = partition_valid_jobs( [valid_candidate, invalid_candidate, another_valid_candidate] @@ -74,25 +73,3 @@ def test_partition_valid_jobs_preserves_order_and_rejections() -> None: assert [job.source_row_id for job in valid_jobs] == ["row-001", "row-003"] assert [row.source_row_id for row in rejected_rows] == ["row-002"] assert rejected_rows[0].reason_code == "missing_job_id" - - -def _candidate( - *, - source_row_id: str = "row-001", - job_id: str | None = "JOB-1001", - customer_name: str | None = "Avery Plumbing", - job_status: str | None = "scheduled", - work_type: str | None = "repair", - scheduled_date: str | None = "2026-06-02", -) -> JobExportCandidate: - return JobExportCandidate( - source_row_id=source_row_id, - job_id=job_id, - customer_name=customer_name, - job_status=job_status, - work_type=work_type, - scheduled_date=scheduled_date, - source_updated_at=datetime(2026, 6, 1, 9, 15, tzinfo=UTC), - last_updated_at=datetime(2026, 6, 2, 8, 45, tzinfo=UTC), - raw_record={"source_row_id": source_row_id, "job_id": job_id}, - )