From 1c02199a697dacdfdfad185c14afe616d97a4cef Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 02:41:40 +0000 Subject: [PATCH 1/9] feat: bootstrap package structure, exceptions, and store interface - Add pyproject.toml with hatchling build backend, Python 3.11+ constraint, and runtime/dev deps (fastapi, httpx, pytest, ruff, black) - Add SyncError base and three domain exceptions (WebhookSignatureError, DuplicateEventError, ReconciliationError) in exceptions.py - Add abstract SyncStore in store/base.py covering last_sync_at tracking, idempotency checks, and the webhook queue interface - Add InMemoryStore in store/memory.py for use in tests only https://claude.ai/code/session_01Q344Pi4fXu3Z5BncPAjvM6 --- pyproject.toml | 18 ++++++++++++++++ src/sync_engine/__init__.py | 0 src/sync_engine/exceptions.py | 14 +++++++++++++ src/sync_engine/store/__init__.py | 0 src/sync_engine/store/base.py | 32 +++++++++++++++++++++++++++++ src/sync_engine/store/memory.py | 34 +++++++++++++++++++++++++++++++ 6 files changed, 98 insertions(+) create mode 100644 pyproject.toml create mode 100644 src/sync_engine/__init__.py create mode 100644 src/sync_engine/exceptions.py create mode 100644 src/sync_engine/store/__init__.py create mode 100644 src/sync_engine/store/base.py create mode 100644 src/sync_engine/store/memory.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..710cca9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,18 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "sync-engine" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [ + "fastapi", + "httpx", + "pytest", + "ruff", + "black", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/sync_engine"] diff --git a/src/sync_engine/__init__.py b/src/sync_engine/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/sync_engine/exceptions.py b/src/sync_engine/exceptions.py new file mode 100644 index 0000000..a0fe76e --- /dev/null +++ b/src/sync_engine/exceptions.py @@ -0,0 +1,14 @@ +class SyncError(Exception): + """Base class for all sync-engine business exceptions.""" + + +class WebhookSignatureError(SyncError): + """Raised when a webhook payload fails HMAC signature verification.""" + + +class DuplicateEventError(SyncError): + """Raised when an event_id has already been processed (idempotency guard).""" + + +class ReconciliationError(SyncError): + """Raised when the REST reconciliation loop encounters an unrecoverable error.""" diff --git a/src/sync_engine/store/__init__.py b/src/sync_engine/store/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/sync_engine/store/base.py b/src/sync_engine/store/base.py new file mode 100644 index 0000000..debbfea --- /dev/null +++ b/src/sync_engine/store/base.py @@ -0,0 +1,32 @@ +from abc import ABC, abstractmethod +from datetime import datetime + + +class SyncStore(ABC): + @abstractmethod + def get_last_sync_at(self) -> datetime | None: + """Return the timestamp of the last confirmed successful sync, or None.""" + + @abstractmethod + def set_last_sync_at(self, dt: datetime) -> None: + """Persist dt as the last successful sync timestamp. + + Must only be called after the sync has been fully committed — never + at the start of a cycle (see invariant #3 in CLAUDE.md). + """ + + @abstractmethod + def is_event_processed(self, event_id: str) -> bool: + """Return True if event_id has already been committed (idempotency check).""" + + @abstractmethod + def mark_event_processed(self, event_id: str) -> None: + """Record event_id as processed. Call only after the payload is committed.""" + + @abstractmethod + def enqueue_webhook(self, event_id: str, payload: dict) -> None: + """Persist a webhook payload before processing begins (at-least-once delivery).""" + + @abstractmethod + def dequeue_unacknowledged(self) -> list[dict]: + """Return all enqueued webhooks that have not yet been marked processed.""" diff --git a/src/sync_engine/store/memory.py b/src/sync_engine/store/memory.py new file mode 100644 index 0000000..5f87cf9 --- /dev/null +++ b/src/sync_engine/store/memory.py @@ -0,0 +1,34 @@ +from datetime import datetime + +from sync_engine.store.base import SyncStore + + +class InMemoryStore(SyncStore): + """In-memory SyncStore implementation — for tests only, no persistence.""" + + def __init__(self) -> None: + self._last_sync_at: datetime | None = None + self._processed: set[str] = set() + self._queue: dict[str, dict] = {} + + def get_last_sync_at(self) -> datetime | None: + return self._last_sync_at + + def set_last_sync_at(self, dt: datetime) -> None: + self._last_sync_at = dt + + def is_event_processed(self, event_id: str) -> bool: + return event_id in self._processed + + def mark_event_processed(self, event_id: str) -> None: + self._processed.add(event_id) + + def enqueue_webhook(self, event_id: str, payload: dict) -> None: + self._queue[event_id] = payload + + def dequeue_unacknowledged(self) -> list[dict]: + return [ + payload + for event_id, payload in self._queue.items() + if event_id not in self._processed + ] From 60a5432064d5f68260429eee2dda30d730e0bc5b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 02:46:04 +0000 Subject: [PATCH 2/9] fix: return (event_id, payload) tuples from dequeue_unacknowledged Callers need the event_id to call mark_event_processed after committing the payload. Returning only the payload made acknowledgment impossible for webhooks whose payload does not redundantly embed the event_id. https://claude.ai/code/session_01Q344Pi4fXu3Z5BncPAjvM6 --- src/sync_engine/store/base.py | 4 ++-- src/sync_engine/store/memory.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/sync_engine/store/base.py b/src/sync_engine/store/base.py index debbfea..8abf788 100644 --- a/src/sync_engine/store/base.py +++ b/src/sync_engine/store/base.py @@ -28,5 +28,5 @@ def enqueue_webhook(self, event_id: str, payload: dict) -> None: """Persist a webhook payload before processing begins (at-least-once delivery).""" @abstractmethod - def dequeue_unacknowledged(self) -> list[dict]: - """Return all enqueued webhooks that have not yet been marked processed.""" + def dequeue_unacknowledged(self) -> list[tuple[str, dict]]: + """Return (event_id, payload) pairs for every queued webhook not yet marked processed.""" diff --git a/src/sync_engine/store/memory.py b/src/sync_engine/store/memory.py index 5f87cf9..e745638 100644 --- a/src/sync_engine/store/memory.py +++ b/src/sync_engine/store/memory.py @@ -26,9 +26,9 @@ def mark_event_processed(self, event_id: str) -> None: def enqueue_webhook(self, event_id: str, payload: dict) -> None: self._queue[event_id] = payload - def dequeue_unacknowledged(self) -> list[dict]: + def dequeue_unacknowledged(self) -> list[tuple[str, dict]]: return [ - payload + (event_id, payload) for event_id, payload in self._queue.items() if event_id not in self._processed ] From 04e2ea2431db1ba87599f2de52b2ab202aa767c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 02:46:32 +0000 Subject: [PATCH 3/9] docs: document PR review workflow in CLAUDE.md https://claude.ai/code/session_01Q344Pi4fXu3Z5BncPAjvM6 --- CLAUDE.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 1318c33..1bccc37 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,3 +71,13 @@ Each package has a single responsibility. Do not let reconciliation logic bleed - Commit messages in English, imperative mood, no trailing period. - Format: `: ` — types: `feat`, `fix`, `docs`, `refactor`, `test`, `chore` - Do not commit secrets, `.env` files, or tokens. + +--- + +## PR review workflow + +After pushing a PR branch: + +1. Read all open review threads (`get_review_comments`). +2. For each thread: apply the fix, reply with the commit SHA and a one-line explanation, then resolve the thread. +3. Once all threads are resolved, post `@codex review` as a top-level PR comment to trigger a follow-up automated review. From f5a812e4b0b83e1b4811d8b9b90a8e2549d467c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 02:52:17 +0000 Subject: [PATCH 4/9] fix: move dev tools to optional-dependencies group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pytest, ruff, and black are not runtime deps — pulling them into production installs is unnecessary. Consumers install with pip install -e .[dev] for local development. https://claude.ai/code/session_01Q344Pi4fXu3Z5BncPAjvM6 --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 710cca9..d5f6e31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,10 @@ requires-python = ">=3.11" dependencies = [ "fastapi", "httpx", +] + +[project.optional-dependencies] +dev = [ "pytest", "ruff", "black", From 136cd435e1cd38cfd5a979ace1cfbc69cdef7d23 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 02:58:11 +0000 Subject: [PATCH 5/9] docs: add ADR-002 for pluggable store interface, document ADR evaluation process ADR-002 captures the decision to define SyncStore as a Python ABC rather than coupling engine components to a concrete backend. Also adds a "when to write a new ADR" section and an ADR index to CLAUDE.md. https://claude.ai/code/session_01Q344Pi4fXu3Z5BncPAjvM6 --- CLAUDE.md | 23 +++++++ docs/adr/ADR-002-pluggable-store-interface.md | 69 +++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 docs/adr/ADR-002-pluggable-store-interface.md diff --git a/CLAUDE.md b/CLAUDE.md index 1bccc37..e489665 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,6 +63,29 @@ Each package has a single responsibility. Do not let reconciliation logic bleed - Valid statuses: `Proposed` | `Accepted` | `Deprecated` | `Superseded by ADR-NNN` - An ADR is not modified after acceptance — create a superseding ADR instead. +### When to write a new ADR + +After every implementation session, ask: *did this introduce an architectural decision not covered by an existing ADR?* + +Write a new ADR when the decision: +- Chooses between two or more structurally different approaches (e.g. abstract interface vs. concrete dependency, REST vs. event-driven). +- Has cross-cutting consequences — it constrains how multiple packages or future implementations must behave. +- Would be non-obvious to reverse without touching many files. + +Do **not** write an ADR for: +- Code conventions already documented in this file (exception hierarchy, naming, formatting). +- Single-file implementation choices with no downstream constraints. +- Bug fixes or adjustments to existing decisions. + +If a new implementation *contradicts* an existing ADR, the new ADR must reference it with status `Superseded by ADR-NNN`. + +### Current ADR index + +| ADR | Title | Status | +|---|---|---| +| [ADR-001](docs/adr/ADR-001-hybrid-rest-webhook.md) | Hybrid REST/webhook architecture | Accepted | +| [ADR-002](docs/adr/ADR-002-pluggable-store-interface.md) | Pluggable store interface via abstract base class | Accepted | + --- ## Git diff --git a/docs/adr/ADR-002-pluggable-store-interface.md b/docs/adr/ADR-002-pluggable-store-interface.md new file mode 100644 index 0000000..8e01b58 --- /dev/null +++ b/docs/adr/ADR-002-pluggable-store-interface.md @@ -0,0 +1,69 @@ +# ADR-002 — Pluggable store interface via abstract base class + +**Status**: Accepted +**Date**: 2026-06-09 +**Deciders**: fullstack-pilot team + +--- + +## Context + +ADR-001 establishes that a persistent store is required for two things: + +1. `last_successful_sync_at` — used by the reconciliation loop to compute the missed-event window after an outage. +2. The webhook queue — at-least-once delivery buffer that survives a crash between reception and processing. + +ADR-001 does not specify the implementation technology or how the rest of the engine should depend on the store. Two options were considered: + +1. **Concrete dependency** — engine components import and instantiate a specific backend directly (e.g. SQLite helper, Redis client). +2. **Abstract interface** — engine components depend on a Python ABC; the concrete backend is injected at startup. + +--- + +## Decision + +We define `SyncStore` as a Python abstract base class (`abc.ABC`) in `src/sync_engine/store/base.py`. All engine components — webhook handler, reconciliation loop, processor — interact exclusively with this interface. No engine code imports a concrete backend. + +The interface surface is intentionally minimal (six methods): + +| Method | Purpose | +|---|---| +| `get_last_sync_at()` | Read the recovery timestamp | +| `set_last_sync_at(dt)` | Write it after a confirmed commit | +| `is_event_processed(event_id)` | Idempotency check before processing | +| `mark_event_processed(event_id)` | Acknowledge after commit | +| `enqueue_webhook(event_id, payload)` | Persist before processing begins | +| `dequeue_unacknowledged()` | Replay buffer on recovery | + +`dequeue_unacknowledged` returns `list[tuple[str, dict]]` — event_id is always returned alongside the payload so callers can acknowledge without the payload embedding its own ID. + +--- + +## Why not a concrete dependency + +- **Test coupling**: any concrete backend (SQLite, Redis) requires setup, teardown, and I/O in tests. An in-memory implementation behind the same interface eliminates all of that. +- **Migration cost**: coupling engine logic to a specific backend turns a backend swap into a cross-cutting refactor. With an interface, only a new implementation file is needed. +- **Invariant enforcement**: the interface is the right place to document the commit-before-acknowledge invariants (see CLAUDE.md §Invariants). Concrete classes are not readable enough for this. + +--- + +## Consequences + +**Positive:** +- `InMemoryStore` (`store/memory.py`) satisfies the interface for all tests — no I/O, no fixtures, no external process. +- Production backends (SQLite, Redis, Postgres) are drop-in replacements — they implement the ABC and are injected at startup. +- Engine invariants (acknowledge-after-commit, last_sync_at write-only-after-success) are enforced at the call site by convention documented on the interface, not scattered across backends. + +**Negative / accepted costs:** +- Dependency injection must be wired at startup — the concrete backend is not self-instantiated. +- Adding a new persistence operation requires updating the ABC and every implementation, not just one file. + +--- + +## Rejected alternatives + +| Alternative | Reason for rejection | +|---|---| +| Hardcoded SQLite backend | Tight coupling; tests require a real file; migration is cross-cutting | +| Dataclass / TypedDict (no ABC) | No enforcement of the interface contract; silent drift between implementations | +| Repository pattern with separate read/write interfaces | Over-engineering for current scope; a single interface covers all six operations cleanly | From 7676df8a4d11379399a4a396fa4b1fbe464ba218 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 03:03:59 +0000 Subject: [PATCH 6/9] test: add unit tests for exceptions and InMemoryStore; add CI workflow - 15 tests covering exception hierarchy and all InMemoryStore behaviors - pytest configured with testpaths and pythonpath = ["src"] for src layout - CI runs lint (ruff + black) and test jobs on every push and PR https://claude.ai/code/session_01Q344Pi4fXu3Z5BncPAjvM6 --- .github/workflows/ci.yml | 32 ++++++++ pyproject.toml | 4 + ...st_exceptions.cpython-311-pytest-9.0.2.pyc | Bin 0 -> 2066 bytes ..._memory_store.cpython-311-pytest-9.0.2.pyc | Bin 0 -> 15095 bytes tests/store/test_memory_store.py | 74 ++++++++++++++++++ tests/test_exceptions.py | 28 +++++++ 6 files changed, 138 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 tests/__pycache__/test_exceptions.cpython-311-pytest-9.0.2.pyc create mode 100644 tests/store/__pycache__/test_memory_store.cpython-311-pytest-9.0.2.pyc create mode 100644 tests/store/test_memory_store.py create mode 100644 tests/test_exceptions.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a587e5f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + pull_request: + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install dev dependencies + run: pip install -e .[dev] + - name: Lint with ruff + run: ruff check . + - name: Check formatting with black + run: black --check . + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install dev dependencies + run: pip install -e .[dev] + - name: Run tests + run: pytest diff --git a/pyproject.toml b/pyproject.toml index d5f6e31..2e6679d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,3 +20,7 @@ dev = [ [tool.hatch.build.targets.wheel] packages = ["src/sync_engine"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] diff --git a/tests/__pycache__/test_exceptions.cpython-311-pytest-9.0.2.pyc b/tests/__pycache__/test_exceptions.cpython-311-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0a4d63868775bf2b85255bc95c9ab587441becee GIT binary patch literal 2066 zcmd6ozi-n(6vywJUv`?ffvTV)RZ1x>1gVJ%q!bBK2C{Z&kf0G@x%SbzI8JnSnue*W z7+Fyfe*lOw@t-7824pD{6I-M#RVUt^6T1y<2d3Kc_k8c&&yU|de@iCg2(CB#x3^mY zLcdvMctM?C{g+T2ARX!0L}koNp%T&rj4I)B1bd!nISL-Zj8(+4Sdq$7C0>qK#>!*h z4}C!8gdPS>>Jd;`kAjZtxP&sXKWviKOxT@xRbsJ$X{k<~5^mP`!UqzCG6YZG0y#hq9@wGS_TQawVEqc# zAHFA#FmhsnSI4LQ`*;>DB0Jm=c0=o^fiYT#9*hd?PFQ_^#jti%)6moQaEY+jXsziG z+i{~*HEd$j2#inJLcgOvo9Z~1+p1Pbu5J^WvthRj#M(40l4HGWt_s=JNR2_Uv$dwX z$UI8JqoMe7DTHBEG_}6D#ICIt*uHKXEp8nLf}I*>M&Y@7+(U zCMBxgOxI{tBeqTSOqhzWa4Iq3pNPc~Wk-fGaFlJ!^}dEa3|@t>=)H)03VN~UJnkzD z;YwGYY2AF%mJ1!Z&=L!byXW9uE(7kP=AZ$UVVcXtI<;0TJx^<@X{O(*hItlZb`VDq zXL)ebQ>ox0!dGF8$qNWCF+@w8@PoFT@5uRq+=lqJF}B@Jpg#Fc!L8yni< z(3x*irTkR8*Sy{GyqXP?DdlIJN1>mUd8(xB%u*IwoWV$p9*d7FRlRPK0-c5??Dt_G vg22`>{>4b0b0_!s-3v#s&_n+xDFH*W_9ZT0$O*nc6$027r~>qVg{QQ?ho{yv literal 0 HcmV?d00001 diff --git a/tests/store/__pycache__/test_memory_store.cpython-311-pytest-9.0.2.pyc b/tests/store/__pycache__/test_memory_store.cpython-311-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..406805f44ddb33ea807c7b955f82b289730616d6 GIT binary patch literal 15095 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 literal 0 HcmV?d00001 diff --git a/tests/store/test_memory_store.py b/tests/store/test_memory_store.py new file mode 100644 index 0000000..4cf385a --- /dev/null +++ b/tests/store/test_memory_store.py @@ -0,0 +1,74 @@ +from datetime import datetime, timezone + +import pytest + +from sync_engine.store.memory import InMemoryStore + + +@pytest.fixture +def store() -> InMemoryStore: + return InMemoryStore() + + +def test_get_last_sync_at_before_any_write_returns_none(store): + assert store.get_last_sync_at() is None + + +def test_set_last_sync_at_get_returns_same_value(store): + dt = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + store.set_last_sync_at(dt) + assert store.get_last_sync_at() == dt + + +def test_set_last_sync_at_overwrites_previous_value(store): + first = datetime(2026, 1, 1, tzinfo=timezone.utc) + second = datetime(2026, 6, 1, tzinfo=timezone.utc) + store.set_last_sync_at(first) + store.set_last_sync_at(second) + assert store.get_last_sync_at() == second + + +def test_is_event_processed_unknown_event_returns_false(store): + assert store.is_event_processed("evt-123") is False + + +def test_is_event_processed_after_mark_returns_true(store): + store.mark_event_processed("evt-123") + assert store.is_event_processed("evt-123") is True + + +def test_mark_event_processed_called_twice_is_idempotent(store): + store.mark_event_processed("evt-123") + store.mark_event_processed("evt-123") + assert store.is_event_processed("evt-123") is True + + +def test_enqueue_webhook_appears_in_dequeue_unacknowledged(store): + store.enqueue_webhook("evt-1", {"type": "created"}) + assert ("evt-1", {"type": "created"}) in store.dequeue_unacknowledged() + + +def test_dequeue_unacknowledged_returns_event_id_alongside_payload(store): + store.enqueue_webhook("evt-1", {"type": "created"}) + event_id, payload = store.dequeue_unacknowledged()[0] + assert event_id == "evt-1" + assert payload == {"type": "created"} + + +def test_dequeue_unacknowledged_empty_before_any_enqueue(store): + assert store.dequeue_unacknowledged() == [] + + +def test_dequeue_unacknowledged_excludes_processed_events(store): + store.enqueue_webhook("evt-1", {"type": "created"}) + store.mark_event_processed("evt-1") + assert store.dequeue_unacknowledged() == [] + + +def test_dequeue_unacknowledged_returns_only_unprocessed_from_mixed_queue(store): + store.enqueue_webhook("evt-1", {"type": "created"}) + store.enqueue_webhook("evt-2", {"type": "updated"}) + store.mark_event_processed("evt-1") + event_ids = [eid for eid, _ in store.dequeue_unacknowledged()] + assert "evt-1" not in event_ids + assert "evt-2" in event_ids diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 0000000..6746315 --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,28 @@ +import pytest + +from sync_engine.exceptions import ( + DuplicateEventError, + ReconciliationError, + SyncError, + WebhookSignatureError, +) + + +def test_webhook_signature_error_caught_as_sync_error(): + with pytest.raises(SyncError): + raise WebhookSignatureError("invalid signature") + + +def test_duplicate_event_error_caught_as_sync_error(): + with pytest.raises(SyncError): + raise DuplicateEventError("evt-123 already processed") + + +def test_reconciliation_error_caught_as_sync_error(): + with pytest.raises(SyncError): + raise ReconciliationError("REST call failed") + + +def test_sync_error_caught_as_base_exception(): + with pytest.raises(Exception): + raise SyncError("base error") From 15ff5bfeba4bab705a658ee987ea644cd6b71289 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 03:04:10 +0000 Subject: [PATCH 7/9] chore: add .gitignore and remove committed pycache files https://claude.ai/code/session_01Q344Pi4fXu3Z5BncPAjvM6 --- .gitignore | 8 ++++++++ ...test_exceptions.cpython-311-pytest-9.0.2.pyc | Bin 2066 -> 0 bytes ...st_memory_store.cpython-311-pytest-9.0.2.pyc | Bin 15095 -> 0 bytes 3 files changed, 8 insertions(+) create mode 100644 .gitignore delete mode 100644 tests/__pycache__/test_exceptions.cpython-311-pytest-9.0.2.pyc delete mode 100644 tests/store/__pycache__/test_memory_store.cpython-311-pytest-9.0.2.pyc diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c1b2887 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +.pytest_cache/ +.ruff_cache/ +*.egg diff --git a/tests/__pycache__/test_exceptions.cpython-311-pytest-9.0.2.pyc b/tests/__pycache__/test_exceptions.cpython-311-pytest-9.0.2.pyc deleted file mode 100644 index 0a4d63868775bf2b85255bc95c9ab587441becee..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2066 zcmd6ozi-n(6vywJUv`?ffvTV)RZ1x>1gVJ%q!bBK2C{Z&kf0G@x%SbzI8JnSnue*W z7+Fyfe*lOw@t-7824pD{6I-M#RVUt^6T1y<2d3Kc_k8c&&yU|de@iCg2(CB#x3^mY zLcdvMctM?C{g+T2ARX!0L}koNp%T&rj4I)B1bd!nISL-Zj8(+4Sdq$7C0>qK#>!*h z4}C!8gdPS>>Jd;`kAjZtxP&sXKWviKOxT@xRbsJ$X{k<~5^mP`!UqzCG6YZG0y#hq9@wGS_TQawVEqc# zAHFA#FmhsnSI4LQ`*;>DB0Jm=c0=o^fiYT#9*hd?PFQ_^#jti%)6moQaEY+jXsziG z+i{~*HEd$j2#inJLcgOvo9Z~1+p1Pbu5J^WvthRj#M(40l4HGWt_s=JNR2_Uv$dwX z$UI8JqoMe7DTHBEG_}6D#ICIt*uHKXEp8nLf}I*>M&Y@7+(U zCMBxgOxI{tBeqTSOqhzWa4Iq3pNPc~Wk-fGaFlJ!^}dEa3|@t>=)H)03VN~UJnkzD z;YwGYY2AF%mJ1!Z&=L!byXW9uE(7kP=AZ$UVVcXtI<;0TJx^<@X{O(*hItlZb`VDq zXL)ebQ>ox0!dGF8$qNWCF+@w8@PoFT@5uRq+=lqJF}B@Jpg#Fc!L8yni< z(3x*irTkR8*Sy{GyqXP?DdlIJN1>mUd8(xB%u*IwoWV$p9*d7FRlRPK0-c5??Dt_G vg22`>{>4b0b0_!s-3v#s&_n+xDFH*W_9ZT0$O*nc6$027r~>qVg{QQ?ho{yv diff --git a/tests/store/__pycache__/test_memory_store.cpython-311-pytest-9.0.2.pyc b/tests/store/__pycache__/test_memory_store.cpython-311-pytest-9.0.2.pyc deleted file mode 100644 index 406805f44ddb33ea807c7b955f82b289730616d6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15095 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 From c1ecddf11be22272810db1b8497da39d05bb0cab Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 03:05:44 +0000 Subject: [PATCH 8/9] docs: add CI status badge to README https://claude.ai/code/session_01Q344Pi4fXu3Z5BncPAjvM6 --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 5158318..6b402dc 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # sync-engine +[![CI](https://github.com/koydas/sync-engine/actions/workflows/ci.yml/badge.svg)](https://github.com/koydas/sync-engine/actions/workflows/ci.yml) + Hybrid REST/webhook sync engine — push/pull coordination, idempotent processing, and failure recovery. Extracted from production integration patterns of `fullstack-pilot`. --- From e029813b76d2d12c239011ef220bcf9b5cf7e593 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 03:13:12 +0000 Subject: [PATCH 9/9] docs: add testing guide and strengthen test requirement in CLAUDE.md docs/testing.md covers layout, naming, fixtures, mocking patterns for webhooks and REST, and a table of required scenarios per module. CLAUDE.md now states explicitly that a feature without tests is not done and links to the guide. https://claude.ai/code/session_01Q344Pi4fXu3Z5BncPAjvM6 --- CLAUDE.md | 12 +++-- docs/testing.md | 119 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 docs/testing.md diff --git a/CLAUDE.md b/CLAUDE.md index e489665..3d3ab25 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,11 +47,17 @@ Each package has a single responsibility. Do not let reconciliation logic bleed ## Tests -- Framework: `pytest` +**Every new module must ship with tests. A feature without a test file is not done.** + +Full guide: [docs/testing.md](docs/testing.md). + +- Framework: `pytest` — run with `pytest` from the repo root. +- Test files mirror the source tree: `src/sync_engine/store/base.py` → `tests/store/test_*.py`. - One test per behavior, not per function. -- Webhook tests mock signature verification (do not depend on secrets in tests). -- Reconciliation tests mock the REST API (no network calls in CI). - Naming: `test___`. +- Webhook tests mock signature verification (never depend on secrets). +- Reconciliation tests mock the REST API (no network calls in CI). +- Use `InMemoryStore` as the store fixture — never a real backend. --- diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..bf18c3b --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,119 @@ +# Testing guide + +## Running tests + +```bash +pip install -e ".[dev]" +pytest # all tests +pytest -v # verbose +pytest tests/store/ # one subdirectory +``` + +--- + +## Layout + +Tests mirror the source tree under `tests/`: + +``` +tests/ +├── test_exceptions.py +├── store/ +│ └── test_memory_store.py +├── webhook/ +│ └── test_handler.py # (upcoming) +├── reconciliation/ +│ └── test_loop.py # (upcoming) +└── processor/ + └── test_processor.py # (upcoming) +``` + +Every new module in `src/sync_engine//` gets a matching test file +in `tests//`. No test file = the feature is not done. + +--- + +## Naming + +``` +test___ +``` + +One test per behavior, not per function. Examples: + +```python +def test_get_last_sync_at_before_any_write_returns_none(): ... +def test_dequeue_unacknowledged_excludes_processed_events(): ... +def test_webhook_signature_error_caught_as_sync_error(): ... +``` + +--- + +## Fixtures + +Define fixtures at the test-file level unless shared across multiple files, in +which case add them to a `conftest.py` in the relevant directory. + +`InMemoryStore` is the canonical fixture for any test that touches the store: + +```python +import pytest +from sync_engine.store.memory import InMemoryStore + +@pytest.fixture +def store() -> InMemoryStore: + return InMemoryStore() +``` + +--- + +## Mocking rules + +### Webhook signature verification + +Never depend on real secrets in tests. Patch the verification call: + +```python +from unittest.mock import patch + +def test_valid_webhook_is_queued(store): + with patch("sync_engine.webhook.handler.verify_signature", return_value=True): + ... +``` + +### REST API (reconciliation) + +No network calls in CI. Patch `httpx.Client` or inject a fake transport: + +```python +import httpx + +def test_reconciliation_fetches_since_last_sync(store): + def handler(request): + return httpx.Response(200, json={"items": []}) + + transport = httpx.MockTransport(handler) + client = httpx.Client(transport=transport) + ... +``` + +--- + +## What to test + +For each new module, cover at minimum: + +| Scenario | Example | +|---|---| +| Happy path | event processed end-to-end | +| Idempotency | same `event_id` processed twice, second is a no-op | +| Pre-condition violated | invalid signature → `WebhookSignatureError` raised | +| State after failure | crash mid-processing → event still in `dequeue_unacknowledged` | + +--- + +## What not to test + +- Internal implementation details (private methods, internal dict structure). +- The abstract `SyncStore` interface directly — test it through `InMemoryStore`. +- Framework behavior (FastAPI routing, httpx serialization).