Skip to content

test: close the async coverage gap (auth caching, lock, error mapping) - #7

Merged
danieyal merged 2 commits into
masterfrom
test/async-coverage
Jul 22, 2026
Merged

test: close the async coverage gap (auth caching, lock, error mapping)#7
danieyal merged 2 commits into
masterfrom
test/async-coverage

Conversation

@danieyal

@danieyal danieyal commented Jul 22, 2026

Copy link
Copy Markdown
Owner

Tests only. No source changes.

Why

The async mirror duplicates the sync caching/refresh logic rather than sharing it (a deliberate non-DRY decision recorded in AGENTS.md), so the sync suite passing said nothing about the async one. Coverage showed the difference:

Module Before Sync twin
_async_auth.py 76% auth.py 95%
_async_client.py 84% client.py 88%

What

tests/unit/test_async_auth.py (new, mirrors test_auth.py):

  • token cache hit — repeated get_token() hits the wire once
  • expiry-driven re-acquisition
  • refresh margin — a token expiring inside the 60s margin is already stale
  • invalidate() drops the cache
  • concurrent get_token() collapses into a single request via asyncio.Lock
  • 401 → AuthenticationError, 500 → MyInvoisError, transport error wrapped

Status-mapping block in tests/unit/test_async_client.py — the async client has its own copy of the response-handling chain, so the equivalent sync assertions did not cover it: 400/401/403/404/429/500 → the right exception type with status_code set, server error-message extraction, non-JSON error bodies, and 204 → None.

The concurrency test is verified to be able to fail

A test that can't fail is worthless, so I checked this one rather than assuming. Replacing AsyncTokenManager._lock with a no-op async context manager turns 1 token request into 10 — it genuinely pins the lock rather than passing vacuously.

Result

_async_auth.py 76% → 92%, _async_client.py 84% → 91%, total 86% → 87%. 284 → 302 tests. ruff check, ruff format --check, mypy src/myinvois all clean.

Found but deliberately not fixed here

TokenManager.is_valid() is a method but AsyncTokenManager.is_valid is a property. That breaks the one-for-one mirror contract both AGENTS.md and the README claim — porting sync code to async hits TypeError: 'bool' object is not callable. It's a public API change, so it belongs in its own PR; recorded in AGENTS.md in the meantime.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Tests
    • Added new async unit coverage for token acquisition, caching, expiry, refresh margins, and invalidation.
    • Verified concurrent refresh collapsing via an async lock so multiple callers share one access token.
    • Expanded async HTTP error handling: status-to-exception mapping, improved message extraction, and correct 204 No Content behavior.
    • Test suite now reports 302 passing tests, including 39 async.
  • Documentation
    • Updated async-progress checkpoint details in project status/testing documentation, clarifying timing/coverage notes and a sync/async validity contract concern.

The async mirror duplicates the sync caching/refresh logic rather than
sharing it, so the sync suite passing said nothing about the async one.
Coverage showed the difference: _async_auth.py 76% vs auth.py 95%.

Adds tests/unit/test_async_auth.py (mirrors test_auth.py):
- token cache hit -- repeated get_token() hits the wire once
- expiry-driven re-acquisition
- refresh margin -- a token expiring inside the 60s margin is already stale
- invalidate() drops the cache
- concurrent get_token() collapses into a single request via asyncio.Lock
- 401 -> AuthenticationError, 500 -> MyInvoisError, transport error wrapped

Adds a status-mapping block to tests/unit/test_async_client.py. The async
client has its own copy of the response-handling chain, so the equivalent
sync assertions did not cover it: 400/401/403/404/429/500 -> the right
exception type with status_code set, server error-message extraction,
non-JSON error bodies, and 204 -> None.

The concurrency test is verified to be able to fail: replacing
AsyncTokenManager._lock with a no-op async context manager turns 1 token
request into 10, so it genuinely pins the lock rather than passing
vacuously.

_async_auth.py 76% -> 92%, _async_client.py 84% -> 91%, total 86% -> 87%.
284 -> 302 tests. ruff, ruff format, mypy src/myinvois all clean.

Tests only; no source changes.

Note for a follow-up PR (deliberately not fixed here): TokenManager.is_valid()
is a method but AsyncTokenManager.is_valid is a property, which breaks the
one-for-one mirror contract AGENTS.md and the README both claim. Recorded in
AGENTS.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 12b361e1-6a84-422f-9da5-d315b98b10a9

📥 Commits

Reviewing files that changed from the base of the PR and between 4c594d1 and 5f388c4.

📒 Files selected for processing (2)
  • AGENTS.md
  • tests/unit/test_async_auth.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • AGENTS.md
  • tests/unit/test_async_auth.py

📝 Walkthrough

Walkthrough

Async authentication coverage was added for token lifecycle, invalidation, refresh concurrency, and endpoint failures. Async client tests now cover HTTP status mapping, error payloads, transport responses, and 204 No Content. Phase 6b documentation was updated with test and coverage status.

Changes

Async coverage expansion

Layer / File(s) Summary
Async token lifecycle and concurrency
tests/unit/test_async_auth.py
Adds tests for token acquisition, caching, expiry, refresh margins, invalidation, and asyncio.Lock-based concurrent refresh collapsing.
Async error and response mapping
tests/unit/test_async_auth.py, tests/unit/test_async_client.py
Verifies authentication, server, transport, and HTTP status errors, response messages, and None for 204 No Content.
Phase 6b status and contract notes
AGENTS.md
Updates test totals and coverage status, documents verified gate timing, and records the sync/async is_valid contract divergence.

Estimated code review effort: 3 (Moderate) | ~20 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 21.43% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the main async test coverage additions around auth caching, locking, and error mapping.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch test/async-coverage

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/unit/test_async_auth.py`:
- Around line 72-81: Update test_async_token_manager_acquires_token to capture
the mocked token route and inspect its recorded request after mgr.get_token().
Assert the submitted form payload includes the expected grant_type, client
credentials, and scope values, while preserving the existing token response and
manager-state assertions.
- Around line 113-124: Update test_async_token_manager_refresh_margin to mock
two token responses, call mgr.get_token() twice, and verify the second call
returns the second token value. Also assert the respx route was called twice,
directly exercising reacquisition when the token is within the refresh margin.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 278b3103-f9fc-4ad7-8559-04daa296d6e0

📥 Commits

Reviewing files that changed from the base of the PR and between 238e380 and 4c594d1.

📒 Files selected for processing (3)
  • AGENTS.md
  • tests/unit/test_async_auth.py
  • tests/unit/test_async_client.py

Comment thread tests/unit/test_async_auth.py
Comment thread tests/unit/test_async_auth.py
Both CodeRabbit findings were valid.

1. test_async_token_manager_acquires_token now asserts the outgoing form
   payload (client_id, client_secret, grant_type, scope). Nothing anywhere
   asserted the grant body, so a typo in _build_form -- "client_credential"
   for instance -- would have passed the whole suite and only failed against
   the real LHDN token endpoint. The sync suite has the same hole.

2. test_async_token_manager_refresh_margin now drives the real path instead
   of only checking the is_valid flag: two mocked responses, two get_token()
   calls, asserting the second returns the fresh token and the route was hit
   twice. This matters because get_token does not consult the is_valid
   property -- it re-checks is_expired inline, twice (outer check, then again
   under the lock). A bug in that inline check would have slipped past the
   flag-only assertion.

   Verified sensitive: with refresh_margin=0 the second get_token returns the
   cached STALE token after a single request, failing the test.

Also recorded in AGENTS.md, found while checking these against their sync
counterparts and left for its own PR:

  tests/unit/test_auth.py::test_token_manager_refresh_margin is VACUOUS. It
  never calls get_token(), so is_valid() returns False only because no token
  was ever acquired. Re-running it with expires_in=3600 -- far outside the
  60s margin -- still passes, with 0 calls to the token endpoint. It would
  pass with the refresh-margin logic deleted entirely.

Tests and notes only; no source changes. 302 tests, ruff/format/mypy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@danieyal
danieyal merged commit a3a04d1 into master Jul 22, 2026
7 checks passed
danieyal added a commit that referenced this pull request Jul 22, 2026
1. AGENTS.md overstated its own verification. It said both refresh-margin
   twins "are verified against refresh_margin=0", which reads as though a
   test in the suite uses that value. None does -- the committed tests use
   the default 60s margin, and refresh_margin=0 was a throwaway scratch
   mutation run that was deleted. Reworded to say what the tests actually do
   and to flag the mutation check as a manual step to repeat if the
   refresh-ahead logic changes.

   Worth fixing carefully: this file spends several sections warning against
   overstated verification claims, so it should not contain one.

2. The reviewer asked for outbound-request assertions on the refresh-margin
   test. The underlying gap is real -- the sync suite never asserted the
   grant payload, a hole noted when the async twin was fixed in #7 -- but the
   margin test is the wrong home for it. That test was just rewritten to do
   exactly one thing: prove a token expiring inside the margin is
   re-acquired. Loading it with request-shape assertions would blur that.

   Added instead to test_token_manager_acquires_token, mirroring
   test_async_auth.py::test_async_token_manager_acquires_token exactly, so
   both suites now pin the grant form in the same place.

   Verified it catches what it exists for: injecting the typo
   "client_credentials" -> "client_credential" into _build_form fails this
   test and ONLY this test, confirming nothing else covered the payload.

Tests and docs only; no source changes. 303 tests, ruff/format/mypy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
danieyal added a commit that referenced this pull request Jul 22, 2026
* test: make the sync refresh-margin test actually test the margin

test_token_manager_refresh_margin never called get_token(), so is_valid was
False merely because no token had ever been acquired -- the tok is None
branch. The refresh margin was never exercised. It passed with
expires_in=3600, far outside the 60s margin, having made 0 calls to the token
endpoint, and would have passed with the refresh-ahead logic deleted
entirely.

auth.py reported 95% line coverage throughout, because coverage counts
executed lines rather than meaningful assertions.

Now mirrors the async twin fixed in #7: two mocked responses, two get_token()
calls, asserting the second returns the fresh token and the route was hit
twice. This drives the real path -- get_token does not consult the is_valid
property, it re-checks is_expired inline, twice (outer check, then again
under the lock).

Verified sensitive: with refresh_margin=0 the second get_token returns the
cached STALE token and the new assertions fail. The old assertion passed
either way.

Both managers' refresh-ahead behaviour is now covered symmetrically.

Tests only; no source changes. 303 tests, ruff/format/mypy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: address review — correct the AGENTS.md claim, pin the grant form

1. AGENTS.md overstated its own verification. It said both refresh-margin
   twins "are verified against refresh_margin=0", which reads as though a
   test in the suite uses that value. None does -- the committed tests use
   the default 60s margin, and refresh_margin=0 was a throwaway scratch
   mutation run that was deleted. Reworded to say what the tests actually do
   and to flag the mutation check as a manual step to repeat if the
   refresh-ahead logic changes.

   Worth fixing carefully: this file spends several sections warning against
   overstated verification claims, so it should not contain one.

2. The reviewer asked for outbound-request assertions on the refresh-margin
   test. The underlying gap is real -- the sync suite never asserted the
   grant payload, a hole noted when the async twin was fixed in #7 -- but the
   margin test is the wrong home for it. That test was just rewritten to do
   exactly one thing: prove a token expiring inside the margin is
   re-acquired. Loading it with request-shape assertions would blur that.

   Added instead to test_token_manager_acquires_token, mirroring
   test_async_auth.py::test_async_token_manager_acquires_token exactly, so
   both suites now pin the grant form in the same place.

   Verified it catches what it exists for: injecting the typo
   "client_credentials" -> "client_credential" into _build_form fails this
   test and ONLY this test, confirming nothing else covered the payload.

Tests and docs only; no source changes. 303 tests, ruff/format/mypy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant