Skip to content

Add contract testing (Schemathesis internal + Pact public)#125

Open
jschoedl wants to merge 3 commits into
mainfrom
contract-testing
Open

Add contract testing (Schemathesis internal + Pact public)#125
jschoedl wants to merge 3 commits into
mainfrom
contract-testing

Conversation

@jschoedl

@jschoedl jschoedl commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds contract testing across both inter-service boundaries, as required by the course best-practices. Each boundary uses the tool that fits its shape:

  • Internal (spring-api → py-recipe/py-help): Schemathesis provider-conformance. Both Python GenAI services are hand-written, so the real risk is provider drift from api/openapi-internal.yaml. Schemathesis derives cases from the spec and checks every response conforms (the LLM is stubbed). It re-syncs itself when the spec changes.
  • Public (web-client → spring-api): consumer-driven Pact. The consumer test exercises the real client code against a mock and generates web-client/pacts/; the provider test replays it against the real Spring app (real JWT/security, Python GenAI clients mocked). Covers every route the client uses, happy and error paths (401/404/409/400).

Type of change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Infrastructure / CI
  • Documentation

API changes

  • This PR does not affect the API
  • This PR changes the API → api/openapi.yaml updated and api/scripts/gen-all.sh re-run

Definition of Done

  • CI passes
  • Pre-commit hooks pass locally
  • Relevant tests added or updated

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added broader contract coverage for key app flows, including authentication, profile management, recipes, and AI assistance.
  • Bug Fixes

    • Improved build validation so service checks run when related API contract files change.
    • Reduced the chance of mismatches between the web client and backend by verifying shared API behavior end to end.
  • Tests

    • Added automated contract tests for the web client, Python services, and Spring API to catch integration issues earlier.

Verify py-recipe-service and py-help-service responses conform to the
internal OpenAPI contract, run in-process over ASGI with the LLM stubbed.
Both service CI workflows now also trigger on api/openapi-internal.yaml.

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

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds Schemathesis-based OpenAPI contract conformance tests for py-help-service and py-recipe-service, and Pact consumer/provider contract tests between web-client and spring-api, with corresponding dev dependency pins, a generated pact file, gitattributes marking, and CI workflow trigger path additions.

Changes

Schemathesis OpenAPI contract tests

Layer / File(s) Summary
py-help-service contract test
services/py-help-service/dev-requirements.txt, services/py-help-service/tests/test_contract.py, .github/workflows/build-help-service.yml
Loads the internal OpenAPI spec, builds a Schemathesis schema excluding /ai/recipes, stubs verify_internal_hmac and get_llm, and validates generated cases; adds pinned schemathesis/pytest-env deps and a CI trigger for the internal spec.
py-recipe-service contract test
services/py-recipe-service/dev-requirements.txt, services/py-recipe-service/tests/test_contract.py, .github/workflows/build-recipe-service.yml
Loads the internal OpenAPI spec, builds a Schemathesis schema excluding /ai/help, stubs verify_internal_hmac and get_llm to return a RecipeListWrapper, and validates generated cases; adds pinned deps and a CI trigger.

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

Pact consumer/provider contract testing

Layer / File(s) Summary
web-client Pact consumer test and pact file
web-client/tests/pact/spring-api.pact.test.ts, web-client/pacts/web-client-spring-api.json, web-client/package.json, .gitattributes
Defines Pact V3 interactions covering auth, profile, recipe CRUD, and AI endpoints plus error cases, exercises real client hooks against a mock server, generates the resulting pact JSON, adds the @pact-foundation/pact dev dependency, and marks the pact directory as generated.
spring-api Pact provider verification
services/spring-api/src/test/kotlin/org/openapitools/pact/SpringApiProviderPactTest.kt, services/spring-api/build.gradle.kts, .github/workflows/build-spring-api.yml
Adds a Spring Boot Pact provider test verifying consumer interactions over HTTP, with @State handlers seeding users/recipes/tokens, mocked downstream AI Retrofit clients, and a Pact provider JUnit 5 dependency plus a CI trigger for pact file changes.

Estimated code review effort: 4 (Complex) | ~55 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Test as spring-api.pact.test.ts
  participant MockServer as Pact Mock Server
  participant ConsumerClient as web-client (auth/useApi)
  participant PactFile as web-client-spring-api.json
  participant ProviderTest as SpringApiProviderPactTest
  participant SpringApi as spring-api

  Test->>MockServer: define interactions (auth, profile, recipes, AI)
  Test->>ConsumerClient: register/login via real client hooks
  ConsumerClient->>MockServer: HTTP requests matching interactions
  MockServer-->>ConsumerClient: mocked responses
  Test->>PactFile: write generated pact contract

  ProviderTest->>PactFile: read consumer interactions
  ProviderTest->>SpringApi: set up `@State` (seed users/recipes/tokens)
  ProviderTest->>SpringApi: replay each interaction via HttpTestTarget
  SpringApi-->>ProviderTest: live HTTP response
  ProviderTest->>ProviderTest: verifyInteraction against contract
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% 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
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding internal Schemathesis and public Pact contract tests.
✨ 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 contract-testing

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Consumer-driven Pact over the public REST boundary: the web-client
consumer test generates web-client/pacts/, which the spring-api provider
test verifies against the real app (JWT auth real, Python GenAI clients
mocked). Covers every route the client uses, happy and error paths.
The committed pact is the filesystem handoff; spring-api CI triggers on it.

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

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown

🚀 Frontend preview ↗  ·  commit b8b1117d2a622b44c6cefa0e1035b3e649caeff1

@jschoedl jschoedl linked an issue Jul 2, 2026 that may be closed by this pull request
@jschoedl jschoedl requested review from imol-ai and paulwiese and removed request for paulwiese July 2, 2026 19:32
@jschoedl jschoedl changed the base branch from optimized-llm-config to main July 9, 2026 13:01
@jschoedl

jschoedl commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (1)
services/py-help-service/tests/test_contract.py (1)

30-44: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

app.dependency_overrides.clear() removes all overrides, not just the two set here.

If the app has pre-existing dependency overrides, clear() will wipe them. Consider removing only the keys you set to avoid unintended side effects on other tests sharing the app singleton.

♻️ Proposed fix: remove only set overrides
 	yield
-	app.dependency_overrides.clear()
+	app.dependency_overrides.pop(verify_internal_hmac, None)
+	app.dependency_overrides.pop(get_llm, None)
🤖 Prompt for 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.

In `@services/py-help-service/tests/test_contract.py` around lines 30 - 44, The
test fixture _stub_provider_dependencies currently wipes every dependency
override on app with app.dependency_overrides.clear(), which can remove
overrides set by other tests or shared setup. Update the teardown to remove only
the two overrides this fixture adds, namely verify_internal_hmac and get_llm,
while leaving any pre-existing app.dependency_overrides entries intact.
🤖 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 `@services/py-help-service/tests/test_contract.py`:
- Around line 15-27: The module-level app.openapi_schema assignment is leaking a
shared schema change across tests; save the original app.openapi_schema before
setting the internal contract schema and restore it after the test run in the
autouse fixture. Use the existing app object and the schema setup around
schemathesis.openapi.from_path / schemathesis.openapi.from_asgi so the internal
contract only applies within this contract test module.

In `@services/py-recipe-service/tests/test_contract.py`:
- Around line 15-27: The module-level mutation of app.openapi_schema in this
test file should be moved behind the existing autouse fixture pattern instead of
happening at import time. Update the contract test setup around app, schema, and
the schemathesis.openapi.from_path call so the original openapi_schema value is
saved before assignment and restored after the test(s) complete, matching the
fix used in the help-service test.

In
`@services/spring-api/src/test/kotlin/org/openapitools/pact/SpringApiProviderPactTest.kt`:
- Around line 94-96: The global bearerToken on SpringApiProviderPactTest is
being injected into every interaction once set, even when the pact request does
not include Authorization. Update verifyPact() so it only substitutes the live
token when the interaction’s request already contains the placeholder
Authorization header, and leave other requests unchanged. Use the existing
bearerToken field, the request-building logic in verifyPact(), and the
provider-state setup methods like aUserExists() to scope injection per
interaction.

In `@web-client/tests/pact/spring-api.pact.test.ts`:
- Around line 97-105: The authenticated `/api/v1/users/profile` interaction in
`spring-api.pact.test.ts` overlaps with the unauthenticated 401 case, so Pact
can match the wrong response. Update the profile contract setup around the
`pact.given(...).uponReceiving(...).withRequest(...).willRespondWith(...)`
interaction so the authenticated case is disjoint from the header-less case,
either by using a separate path/matcher or by moving the unauthenticated
scenario into a separate test. Ensure only one interaction can match each
request.

---

Nitpick comments:
In `@services/py-help-service/tests/test_contract.py`:
- Around line 30-44: The test fixture _stub_provider_dependencies currently
wipes every dependency override on app with app.dependency_overrides.clear(),
which can remove overrides set by other tests or shared setup. Update the
teardown to remove only the two overrides this fixture adds, namely
verify_internal_hmac and get_llm, while leaving any pre-existing
app.dependency_overrides entries intact.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: aaa78a2a-97d4-42cf-8c8a-14f531f97dc9

📥 Commits

Reviewing files that changed from the base of the PR and between 6a4ccc2 and b8b1117.

⛔ Files ignored due to path filters (1)
  • web-client/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (13)
  • .gitattributes
  • .github/workflows/build-help-service.yml
  • .github/workflows/build-recipe-service.yml
  • .github/workflows/build-spring-api.yml
  • services/py-help-service/dev-requirements.txt
  • services/py-help-service/tests/test_contract.py
  • services/py-recipe-service/dev-requirements.txt
  • services/py-recipe-service/tests/test_contract.py
  • services/spring-api/build.gradle.kts
  • services/spring-api/src/test/kotlin/org/openapitools/pact/SpringApiProviderPactTest.kt
  • web-client/package.json
  • web-client/pacts/web-client-spring-api.json
  • web-client/tests/pact/spring-api.pact.test.ts

Comment on lines +15 to +27
_CONTRACT = (
pathlib.Path(__file__).resolve().parents[3] / "api" / "openapi-internal.yaml"
)
app.openapi_schema = schemathesis.openapi.from_path(_CONTRACT).raw_schema

# /ai/recipes belongs to py-recipe-service
schema = schemathesis.openapi.from_asgi("/openapi.json", app).exclude(
path="/ai/recipes"
)

# Happy path only. Auth rejection (missing/invalid HMAC headers) is covered in test_help_service.py.
schema.config.generation.update(modes=[schemathesis.GenerationMode.POSITIVE])
schema.config.phases.update(phases=["examples", "fuzzing"])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Module-level app.openapi_schema mutation persists without restoration.

app.openapi_schema is set at import time (line 18) and never restored. Since app is a shared singleton from main, any other test module in the same session that relies on the auto-generated OpenAPI schema will see the internal contract schema instead. Consider saving and restoring the original value in the autouse fixture.

🛡️ Proposed fix: save/restore openapi_schema in fixture
 app.openapi_schema = schemathesis.openapi.from_path(_CONTRACT).raw_schema
 
 # /ai/recipes belongs to py-recipe-service
 schema = schemathesis.openapi.from_asgi("/openapi.json", app).exclude(
 	path="/ai/recipes"
 )
 
 # Happy path only. Auth rejection (missing/invalid HMAC headers) is covered in test_help_service.py.
 schema.config.generation.update(modes=[schemathesis.GenerationMode.POSITIVE])
 schema.config.phases.update(phases=["examples", "fuzzing"])
 
+_original_openapi_schema = app.openapi_schema
+
 `@pytest.fixture`(autouse=True)
 def _stub_provider_dependencies():
 	app.dependency_overrides[verify_internal_hmac] = lambda: None
@@ -42,7 +44,10 @@
 	yield
 	app.dependency_overrides.clear()
+	app.openapi_schema = _original_openapi_schema
🤖 Prompt for 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.

In `@services/py-help-service/tests/test_contract.py` around lines 15 - 27, The
module-level app.openapi_schema assignment is leaking a shared schema change
across tests; save the original app.openapi_schema before setting the internal
contract schema and restore it after the test run in the autouse fixture. Use
the existing app object and the schema setup around
schemathesis.openapi.from_path / schemathesis.openapi.from_asgi so the internal
contract only applies within this contract test module.

Comment on lines +15 to +27
_CONTRACT = (
pathlib.Path(__file__).resolve().parents[3] / "api" / "openapi-internal.yaml"
)
app.openapi_schema = schemathesis.openapi.from_path(_CONTRACT).raw_schema

# /ai/help belongs to py-help-service
schema = schemathesis.openapi.from_asgi("/openapi.json", app).exclude(path="/ai/help")

# Happy path only. Auth rejection (missing/invalid HMAC headers) is covered in test_recipe_service.py.
schema.config.generation.update(modes=[schemathesis.GenerationMode.POSITIVE])
schema.config.phases.update(phases=["examples", "fuzzing"])


Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Same module-level app.openapi_schema concern as help-service test.

This file has the identical pattern: app.openapi_schema is mutated at module level (line 18) without restoration. The same fix applies — save and restore in the autouse fixture.

🤖 Prompt for 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.

In `@services/py-recipe-service/tests/test_contract.py` around lines 15 - 27, The
module-level mutation of app.openapi_schema in this test file should be moved
behind the existing autouse fixture pattern instead of happening at import time.
Update the contract test setup around app, schema, and the
schemathesis.openapi.from_path call so the original openapi_schema value is
saved before assignment and restored after the test(s) complete, matching the
fix used in the help-service test.

Comment on lines +94 to +96
/** Bearer token for the current interaction, set when a state creates a user. */
private var bearerToken: String? = null

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Header injection isn't scoped to interactions that actually require Authorization.

verifyPact() unconditionally injects the live bearer token into every request once bearerToken is non-null — but bearerToken gets set by shared provider-state methods like aUserExists() (Line 112-115), which is also the state for "a valid login request". That pact interaction has no Authorization header in its recorded request, yet this code adds one anyway, breaking exact contract replay for that interaction. This works "by accident" only because Spring Security presumably ignores a stray header on the public /users/login endpoint — but it silently deviates from the pact and could mask real security-filter regressions.

Only substitute the token when the request built from the pact interaction already carries the placeholder Authorization header:

🔧 Proposed fix
 	fun verifyPact(
 		context: PactVerificationContext,
 		request: HttpRequest,
 	) {
-		bearerToken?.let { request.setHeader("Authorization", "Bearer $it") }
+		if (request.containsHeader("Authorization")) {
+			bearerToken?.let { request.setHeader("Authorization", "Bearer $it") }
+		}
 		context.verifyInteraction()
 	}

Also applies to: 107-115, 151-159

🤖 Prompt for 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.

In
`@services/spring-api/src/test/kotlin/org/openapitools/pact/SpringApiProviderPactTest.kt`
around lines 94 - 96, The global bearerToken on SpringApiProviderPactTest is
being injected into every interaction once set, even when the pact request does
not include Authorization. Update verifyPact() so it only substitutes the live
token when the interaction’s request already contains the placeholder
Authorization header, and leave other requests unchanged. Use the existing
bearerToken field, the request-building logic in verifyPact(), and the
provider-state setup methods like aUserExists() to scope injection per
interaction.

Comment on lines +97 to +105
pact
.given('a user testuser exists')
.uponReceiving('an authenticated request for the current user profile')
.withRequest({ method: 'GET', path: '/api/v1/users/profile', headers: bearer })
.willRespondWith({
status: 200,
headers: jsonResponse,
body: like({ username: 'testuser', preferences: {} }),
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

sed -n '1,320p' web-client/tests/pact/spring-api.pact.test.ts

Repository: AET-DevOps26/team-devsecops

Length of output: 10383


🏁 Script executed:

rg -n '"`@pact-foundation/pact`"|pact' web-client/package.json package.json pnpm-lock.yaml yarn.lock package-lock.json

Repository: AET-DevOps26/team-devsecops

Length of output: 448


🌐 Web query:

Pact V3 mock server matching two interactions same method and path one with required Authorization header and one without headers, does a request with Authorization match both or choose the most specific interaction

💡 Result:

In Pact, the mock server does not have an explicit built-in mechanism to prioritize the "most specific" interaction when multiple interactions match the same incoming request [1]. When multiple interactions qualify as a match, the mock server typically returns a warning and defaults to selecting one—often based on the order in which they appear in the contract file [1]. While Pact provides a sophisticated "matcher selection algorithm" for determining how to match nested JSON elements or body contents using weighted paths [2][3][4], this logic does not automatically apply to the selection between two different top-level interactions that both satisfy the request method, path, and headers. Because Pact's matching philosophy for requests is generally strict—requiring that all defined expectations must be met [5][6]—a request containing an Authorization header will technically "match" an interaction that requires an Authorization header, and it will also "match" an interaction that defines no headers (as Pact allows extra, unexpected headers in requests) [7][6]. When the mock server encounters this ambiguity, it does not guarantee the selection of the more specific match [1]. To manage this behavior: 1. Ordering: Many users work around this by ensuring the most specific interaction appears later in the contract (or within the test suite definition) so that it is registered or evaluated in a way that overrides the less specific one [1]. 2. Strictness: If you need to distinguish between these two scenarios, you must define the expectations in a way that the "less specific" interaction would fail to match the "more specific" request. For example, if you define the "no headers" interaction to explicitly exclude the Authorization header (if the implementation supports it) or rely on creating separate tests where only one interaction is active at a time to prevent overlapping matches [6]. In summary, the mock server will not reliably choose the most specific interaction by default [1]. You should structure your tests to avoid having multiple interactions that are simultaneously valid for the same request [6].

Citations:


Avoid overlapping /users/profile interactions
The authenticated 200 response and the header-less 401 response can both match the same request. Pact doesn’t reliably pick the more specific interaction here, so the authenticated profile call can resolve to the wrong response intermittently. Split the unauthenticated case into a disjoint matcher/path or a separate test so only one interaction can match.

🤖 Prompt for 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.

In `@web-client/tests/pact/spring-api.pact.test.ts` around lines 97 - 105, The
authenticated `/api/v1/users/profile` interaction in `spring-api.pact.test.ts`
overlaps with the unauthenticated 401 case, so Pact can match the wrong
response. Update the profile contract setup around the
`pact.given(...).uponReceiving(...).withRequest(...).willRespondWith(...)`
interaction so the authenticated case is disjoint from the header-less case,
either by using a separate path/matcher or by moving the unauthenticated
scenario into a separate test. Ensure only one interaction can match each
request.

@imol-ai imol-ai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

good work! feel free to merge after fixing the conflict

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.

Api contract testing

2 participants