Skip to content

feat(tests,staged_upload): empty-batch contract for StagedDestination Protocol + bug fix (Step 2e)#606

Merged
masukai merged 1 commit into
mainfrom
feat/empty-batch-staged-destinations
Jun 1, 2026
Merged

feat(tests,staged_upload): empty-batch contract for StagedDestination Protocol + bug fix (Step 2e)#606
masukai merged 1 commit into
mainfrom
feat/empty-batch-staged-destinations

Conversation

@masukai

@masukai masukai commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes out the empty-batch contract suite (#593 HTTP × 4 / #594 file × 4 / #595 SQL × 4 / #604 API × 11 / #605 special × 2). This PR adds the StagedDestination Protocol shape for staged_upload + salesforce_bulkand surfaces a real bug in staged_upload that the contract caught.

Coverage now complete: 24 of 24 registered destinations

Module Destinations
#593 HTTP webhook slack / discord / teams / rest_api (4)
#594 file csv / json / jsonl / parquet (4)
#595 SQL postgres / mysql / clickhouse / snowflake (4)
#604 hardcoded-endpoint API hubspot / jira / linear / notion / twilio / amplitude / zendesk / google_ads / sendgrid / intercom / github_actions (11)
#605 special-transport email_smtp / google_sheets (2)
this PR staged_upload / salesforce_bulk (2)

Bug surfaced + fixed in same PR: staged_upload empty short-circuit

StagedUploadDestination.finalize() was running the full Phase 1 upload → Phase 2 trigger → Phase 3 poll lifecycle even when self._records was empty:

  • Phase 1: serialize 0 records → 0-byte file → POST to stage.url
  • Phase 2: POST to trigger.url with the (empty) upload reference
  • Phase 3: poll the job until completion or timeout

SalesforceBulkDestination.finalize() already had the right guard (if not self._records: return SyncResult(rows_extracted=0) at the top). staged_upload was missing it.

Fix: added the same short-circuit immediately after record_count = len(self._records) in StagedUploadDestination.finalize(). The contract test now passes.

Contract shape (StagedDestination Protocol)

The Protocol is stage(records, config, opts) -> None + finalize(config, opts) -> SyncResult. Three contracts:

  1. isinstance(dest, StagedDestination) — Protocol satisfaction
  2. After only empty stage([]) call(s), finalize() returns SyncResult(0, 0, 0)
  3. finalize() makes zero httpx.Client.send calls after only empty stage([]) call(s)

The third is load-bearing because the engine calls finalize() regardless of whether any batch staged records.

Tripwire mechanism (same as #604 / #605)

Records attempted httpx.Client.send / AsyncClient.send calls into a captured list rather than raising inside the patch — broad except Exception row-error handlers in destinations would swallow an AssertionError raised from the tripwire, masking the bug the contract is meant to catch.

Test plan

  • pytest tests/contracts/test_destination_staged_empty_batch.py — 6 pass (after fix; staged_upload fails before the fix)
  • pytest tests/contracts/ — full suite 74 pass (68 from feat(tests): empty-batch contract for email_smtp + google_sheets (Step 2d) #605 + 6 new)
  • pytest tests/unit/test_staged_upload.py tests/unit/test_salesforce_bulk.py — 15 existing pass (no regression from the staged_upload short-circuit addition)
  • ruff check + mypy — clean
  • CI green

🤖 Generated with Claude Code

… Protocol + bug fix (Step 2e)

Closes out the empty-batch contract suite by adding
tests/contracts/test_destination_staged_empty_batch.py covering the
two destinations that use the StagedDestination Protocol (stage() +
finalize() shape) — staged_upload and salesforce_bulk.

Contracts under test:

1. isinstance(dest, StagedDestination) — Protocol satisfaction
2. stage([]) then finalize() returns SyncResult(0, 0, 0)
3. finalize() makes zero httpx.Client.send calls after only empty
   stage([]) call(s) — the load-bearing contract, since the engine
   calls finalize() regardless of whether any batch staged records

The third contract surfaced a real bug in StagedUploadDestination:
finalize() ran the full Phase 1 upload → Phase 2 trigger → Phase 3
poll lifecycle even when self._records was empty, POSTing a 0-byte
file to stage.url and then POSTing to trigger.url — wasting a job-id
allocation on a zero-row payload. SalesforceBulkDestination already
had the right guard at the top of finalize() (`if not self._records:
return SyncResult(rows_extracted=0)`); staged_upload was missing it.

Fix: added the same empty-source short-circuit immediately after
`record_count = len(self._records)`. No auth, upload, trigger, or
poll work runs when nothing was staged.

Tripwire mechanism is the same record-then-assert pattern as #604 /
#605 — captures attempted httpx.Client.send / AsyncClient.send calls
into a list rather than raising inside the patch, so broad
except Exception row-error handlers in destinations can't swallow
the AssertionError and mask the bug.

6 new tests (2 destinations × 3 contracts). Full contract suite now
74 tests, all passing.

Empty-batch contract suite final tally: 24 of 24 registered
destinations covered.

- #593 HTTP webhook × 4 (slack/discord/teams/rest_api)
- #594 file × 4 (csv/json/jsonl/parquet)
- #595 SQL × 4 (postgres/mysql/clickhouse/snowflake)
- #604 hardcoded-endpoint API × 11
  (hubspot/jira/linear/notion/twilio/amplitude/zendesk/
   google_ads/sendgrid/intercom/github_actions)
- #605 special-transport × 2 (email_smtp/google_sheets)
- this PR × 2 stage-shape (staged_upload/salesforce_bulk)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@codecov

codecov Bot commented Jun 1, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@masukai masukai merged commit 7c250ac into main Jun 1, 2026
8 checks passed
@masukai masukai deleted the feat/empty-batch-staged-destinations branch June 1, 2026 13:19
@github-actions github-actions Bot locked and limited conversation to collaborators Jun 1, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant