Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,6 @@ services/spring-api/.openapi-generator-ignore linguist-generated=true

# openapi-typescript output
web-client/src/api.ts linguist-generated=true

# Pact contract (generated by the web-client consumer test, verified by spring-api)
web-client/pacts/** linguist-generated=true
1 change: 1 addition & 0 deletions .github/workflows/build-help-service.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
push:
paths:
- 'services/py-help-service/**'
- 'api/openapi-internal.yaml'
- '.github/workflows/build-help-service.yml'
workflow_dispatch:
workflow_call:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/build-recipe-service.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
push:
paths:
- 'services/py-recipe-service/**'
- 'api/openapi-internal.yaml'
- '.github/workflows/build-recipe-service.yml'
workflow_dispatch:
workflow_call:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/build-spring-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
push:
paths:
- 'services/spring-api/**'
- 'web-client/pacts/**'
- '.github/workflows/build-spring-api.yml'
workflow_dispatch:
workflow_call:
Expand Down
3 changes: 2 additions & 1 deletion services/py-help-service/dev-requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ pytest
pytest-cov
httpx
pytest-asyncio
pytest-env
pytest-env
schemathesis==4.22.3
49 changes: 49 additions & 0 deletions services/py-help-service/tests/test_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""OpenAPI contract-conformance test for py-help-service using Schemathesis.

Verifies that the service's live responses conform to the internal contract in
`api/openapi-internal.yaml`.
"""

import pathlib
from unittest.mock import AsyncMock, MagicMock

import pytest
import schemathesis

from main import app, get_llm, verify_internal_hmac, LocalHelpResponse

_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"])


@pytest.fixture(autouse=True)
def _stub_provider_dependencies():
app.dependency_overrides[verify_internal_hmac] = lambda: None

conforming = LocalHelpResponse(
response="Add a pinch of salt to balance the flavour."
)
structured_runnable = AsyncMock()
structured_runnable.ainvoke.return_value = conforming
llm = MagicMock()
llm.with_structured_output.return_value = structured_runnable
app.dependency_overrides[get_llm] = lambda: llm

yield
app.dependency_overrides.clear()


@schema.parametrize()
def test_openapi_conformance(case):
case.call_and_validate()
3 changes: 2 additions & 1 deletion services/py-recipe-service/dev-requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ pytest
pytest-cov
httpx
pytest-asyncio
pytest-env
pytest-env
schemathesis==4.22.3
55 changes: 55 additions & 0 deletions services/py-recipe-service/tests/test_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""OpenAPI contract-conformance test for py-recipe-service using Schemathesis.

Verifies that the service's live responses conform to the internal contract in
`api/openapi-internal.yaml`.
"""

import pathlib
from unittest.mock import AsyncMock, MagicMock

import pytest
import schemathesis

from main import app, get_llm, verify_internal_hmac, RecipeListWrapper, LocalRecipeInput

_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"])


@pytest.fixture(autouse=True)
def _stub_provider_dependencies():
app.dependency_overrides[verify_internal_hmac] = lambda: None

conforming = RecipeListWrapper(
recipes=[
LocalRecipeInput(
title="Test Recipe",
ingredients=[{"quantity": 1.0, "unit": "cup", "name": "Flour"}],
instructions=["Mix.", "Bake."],
portions=2.0,
nutrients={"calories": 200, "protein": 5, "fat": 3, "carbs": 35},
)
]
)
structured_runnable = AsyncMock()
structured_runnable.ainvoke.return_value = conforming
llm = MagicMock()
llm.with_structured_output.return_value = structured_runnable
app.dependency_overrides[get_llm] = lambda: llm

yield
app.dependency_overrides.clear()


@schema.parametrize()
def test_openapi_conformance(case):
case.call_and_validate()
95 changes: 49 additions & 46 deletions services/spring-api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -20,35 +20,35 @@ repositories {
}

kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
freeCompilerArgs.add("-Xannotation-default-target=param-property")
}

sourceSets {
getByName("test") {
kotlin.srcDir("src/test/kotlin")
}
}
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
freeCompilerArgs.add("-Xannotation-default-target=param-property")
}

sourceSets {
getByName("test") {
kotlin.srcDir("src/test/kotlin")
}
}
}

tasks.test {
useJUnitPlatform()

jvmArgs("-XX:+EnableDynamicAgentLoading")

reports {
junitXml.required.set(true)
html.required.set(true)
}

testLogging {
events("passed", "skipped", "failed")
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
showExceptions = true
showCauses = true
}
finalizedBy(tasks.jacocoTestReport)
useJUnitPlatform()

jvmArgs("-XX:+EnableDynamicAgentLoading")

reports {
junitXml.required.set(true)
html.required.set(true)
}

testLogging {
events("passed", "skipped", "failed")
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
showExceptions = true
showCauses = true
}
finalizedBy(tasks.jacocoTestReport)
}

tasks.jacocoTestReport {
Expand Down Expand Up @@ -94,24 +94,27 @@ dependencies {
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6")

// Tests
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("org.springframework.boot:spring-boot-starter-test") {
exclude(module = "junit")
}
testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0")
testImplementation("org.springframework.boot:spring-boot-webmvc-test")
testImplementation("org.springframework.security:spring-security-test")

// Retrofit
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-jackson:2.11.0")

// OkHttp
implementation("com.squareup.okhttp3:okhttp:4.12.0")

// JSON multiplatform runtime
implementation("com.squareup.moshi:moshi:1.15.1")
implementation("com.squareup.moshi:moshi-kotlin:1.15.1")
implementation("com.squareup.retrofit2:converter-moshi:2.11.0")
// Tests
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("org.springframework.boot:spring-boot-starter-test") {
exclude(module = "junit")
}
testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0")
testImplementation("org.springframework.boot:spring-boot-webmvc-test")
testImplementation("org.springframework.security:spring-security-test")

// Pact provider-side contract verification (web-client -> spring-api)
testImplementation("au.com.dius.pact.provider:junit5:4.6.17")

// Retrofit
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-jackson:2.11.0")

// OkHttp
implementation("com.squareup.okhttp3:okhttp:4.12.0")

// JSON multiplatform runtime
implementation("com.squareup.moshi:moshi:1.15.1")
implementation("com.squareup.moshi:moshi-kotlin:1.15.1")
implementation("com.squareup.retrofit2:converter-moshi:2.11.0")
}
Loading
Loading