test(connectors/google_mail,google_calendar): phase 14 audit cluster D2 (mapper edge case + 401 insufficient scope + fixture activation)#314
Merged
ozzy-3 merged 1 commit intoMay 31, 2026
Conversation
…D2 (mapper edge case + 401 insufficient scope + fixture activation) Phase 14 audit cluster D2 — pins test coverage gaps and adds an actionable error path for the OQ6 re-consent scenario surfaced by the read-only audit (4 perspectives, parallel). Mapper edge cases (G-4 / G-5 / G-7): - Calendar mapper: RDATE / EXDATE / EXRULE preservation in body (`recurrence: tuple[str, ...]` may carry multiple components, not just RRULE); complex RRULE (BYDAY multi-day + UNTIL + COUNT) pin - Calendar mapper: 1-attendee body shape (`Attendees:\nalice@...`, the boundary between empty and multi-attendee) - Calendar mapper: kanji / emoji / control-char preservation in description + location (text-only family, no sanitisation per ADR-0010 §Phase 14 改訂 (k)) - Gmail mapper: kanji / emoji / control-char preservation; pins that `_decode_gmail_body`'s `errors="replace"` is on the UTF-8 boundary only — decoded control chars and U+FFFD round-trip Client edge cases (G-6 / G-9): - Calendar client: `start.timeZone` / `end.timeZone` retained on `RawCalendarEvent.raw` (no dedicated dataclass field — Phase 14 G4 deliberate design, mapper consumes `dateTime` offset directly) - Both clients: 401 `insufficient_scope` detection (JSON body `error_subtype` or `WWW-Authenticate` header) → `GoogleAuthError` with actionable re-auth hint (`opshub connector auth set google_workspace`). Phase 14 G2 OQ6 scenario coverage. Non- insufficient-scope 401s still raise `ConnectorFailedError`. Fixture activation (F-1): - Calendar `test_client.py`: `_fixture(name)` helper mirrors the Gmail-side pattern. `events_single.json` / `events_recurring_with_override.json` / `sync_token_gone.json` now back live tests (previously unreferenced). - `tests/fixtures/google_calendar/README.md` documents the fixture set + the inline-JSON / fixture split rationale (matches Phase 14 plan §7.5 listing: `single / recurring + override / 410 GONE`). - No `events_all_day.json` fixture added — §7.5 does not list it and the all-day shape is adequately pinned by inline JSON in `test_normaliser_handles_all_day_events`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #309.
Phase 14 audit cluster D2 — unit-level coverage gaps surfaced by the read-only audit (4 perspectives, parallel). Cluster D1 (#313, integration) and clusters B1 / B2 / C (docs) are merged; this PR finishes the audit cycle.
Summary
Mapper edge cases (G-4 / G-5 / G-7)
recurrence: tuple[str, ...]may carry RRULE + RDATE + EXDATE + EXRULE per RFC 5545 —test_master_event_body_includes_rdate_and_exdatepins that every component lands in body verbatim,test_master_event_body_preserves_complex_rrule_bydaypins that a complex RRULE (BYDAY=MO,WE,FR;UNTIL=...;COUNT=52) round-trips without normalisation.test_body_handles_single_attendeepins the 1-attendee boundary (Attendees:\nalice@example.com— no special-case suppression vs the 0-attendee / multi-attendee paths)._decode_gmail_body'serrors="replace"is on the UTF-8 boundary only (decoded control chars + U+FFFD round-trip intact). Calendar-side pins symmetric behaviour on description + location (text-only family — no sanitisation per ADR-0010 §Phase 14 改訂 (k)).Client edge cases (G-6 / G-9)
start.timeZone/end.timeZoneare kept onRawCalendarEvent.rawonly (no dedicated dataclass field — Phase 14 G4 deliberate design because the mapper consumesdateTimewith its offset directly). Pinned so a future regression that drops or filtersrawtrips before silently losing forensic context for Phase 15+ projection work.insufficient_scopedetection added. Code change in bothclient.pyfiles: a new_is_insufficient_scope(response)helper inspects theWWW-Authenticateheader AND the JSON body ({"error": "invalid_token", "error_subtype": "insufficient_scope"}ORerror.status="PERMISSION_DENIED"+insufficientin message). Matches →GoogleAuthError(subclass ofConfigError) with actionable message naming the recovery command (opshub connector auth set google_workspace). Non-insufficient_scope401s still raiseConnectorFailedError(test_non_rate_limit_4xx_fails_fast/test_other_4xx_fails_fastcontinue to pin generic behaviour).This is the Phase 14 G2 OQ6 scenario: operator carrying a Phase 13 drive-only refresh token forward into Phase 14 G3 / G4 without re-running the paste-code flow. The actionable error short-circuits the retry loop the operator would otherwise enter.
Fixture activation (F-1)
test_client.pynow uses_fixture(name)helper mirroring the Gmail-side pattern. The three previously-unreferenced fixtures all back live tests:events_single.json→test_events_single_fixture_normalises_into_raw_calendar_event+test_normaliser_preserves_time_zone_field_on_raw_payload(G-6)events_recurring_with_override.json→test_events_recurring_with_override_fixture_yields_master_plus_overridesync_token_gone.json→test_fetch_events_delta_410_raises_sync_token_expired(refactored from inline JSON)tests/fixtures/google_calendar/README.mddocuments the fixture set, mirrors Gmail-sideREADME.mdstructure, and explains the inline-JSON / fixture split: pagination / retry / sentinel tests stay inline because the fixture file shape would couple the pin to fixture content drift (the Gmail-sidehistory_page.jsonactivation made the same split).Phase 14 plan §7.5 alignment
§7.5 lists
single / recurring + override / 410 GONEfor calendar fixtures. All three are now activated. Noevents_all_day.jsonfixture added — §7.5 does not list it and the all-day shape is adequately pinned by inline JSON in the existingtest_normaliser_handles_all_day_eventstest.Existing 401 test vs new insufficient_scope test — boundary
tests/unit/connectors/google_mail/test_client.py::test_non_rate_limit_4xx_fails_fast— plain 401 (noinsufficient_scopesignal) →ConnectorFailedError(1 call, no retry)tests/unit/connectors/google_calendar/test_client.py::test_other_4xx_fails_fast— generic 4xx (400 here) →ConnectorFailedError(1 call, no retry)test_<gmail|calendar>_401_insufficient_scope_actionable_message+test_<gmail|calendar>_401_insufficient_scope_via_www_authenticate_header— 401 with the OAuth signal →GoogleAuthError(1 call, no retry, actionable message)Test plan
uv run pytest tests/unit/connectors/google_calendar/ tests/unit/connectors/google_mail/→ 116 passeduv run pytest tests/unit/connectors/test_mapper_symmetry.py tests/unit/connectors/google_auth/→ 37 passed (no regression)uv run pytest tests/unit/(with--all-extras) → 2384 passeduv run pytest tests/integration/ -k "google_mail or google_calendar"→ 9 passeduv run ruff check . && uv run ruff format --check .→ cleanuv run pyright src/opshub/connectors/google_calendar/ src/opshub/connectors/google_mail/ tests/unit/connectors/google_calendar/ tests/unit/connectors/google_mail/→ 0 errorsuv run mypy src/opshub/connectors/google_calendar/ src/opshub/connectors/google_mail/→ no issues