Skip to content

Commit 8a4e62a

Browse files
authored
Merge pull request #127 from AET-DevOps26/feat/recipe-editing-ct
Add recipe editing to the web client
2 parents d36458b + 686337c commit 8a4e62a

27 files changed

Lines changed: 4802 additions & 124 deletions

.gitattributes

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,6 @@ services/spring-api/.openapi-generator-ignore linguist-generated=true
1616

1717
# openapi-typescript output
1818
web-client/src/api.ts linguist-generated=true
19+
20+
# Pact contract (generated by the web-client consumer test, verified by spring-api)
21+
web-client/pacts/** linguist-generated=true

.github/workflows/build-help-service.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ on:
44
push:
55
paths:
66
- 'services/py-help-service/**'
7+
- 'api/openapi-internal.yaml'
78
- '.github/workflows/build-help-service.yml'
89
workflow_dispatch:
910
workflow_call:

.github/workflows/build-recipe-service.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ on:
44
push:
55
paths:
66
- 'services/py-recipe-service/**'
7+
- 'api/openapi-internal.yaml'
78
- '.github/workflows/build-recipe-service.yml'
89
workflow_dispatch:
910
workflow_call:

.github/workflows/build-spring-api.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ on:
44
push:
55
paths:
66
- 'services/spring-api/**'
7+
- 'web-client/pacts/**'
78
- '.github/workflows/build-spring-api.yml'
89
workflow_dispatch:
910
workflow_call:

services/py-help-service/dev-requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ pytest
33
pytest-cov
44
httpx
55
pytest-asyncio
6-
pytest-env
6+
pytest-env
7+
schemathesis==4.22.3
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""OpenAPI contract-conformance test for py-help-service using Schemathesis.
2+
3+
Verifies that the service's live responses conform to the internal contract in
4+
`api/openapi-internal.yaml`.
5+
"""
6+
7+
import pathlib
8+
from unittest.mock import AsyncMock, MagicMock
9+
10+
import pytest
11+
import schemathesis
12+
13+
from main import app, get_llm, verify_internal_hmac, LocalHelpResponse
14+
15+
_CONTRACT = (
16+
pathlib.Path(__file__).resolve().parents[3] / "api" / "openapi-internal.yaml"
17+
)
18+
app.openapi_schema = schemathesis.openapi.from_path(_CONTRACT).raw_schema
19+
20+
# /ai/recipes belongs to py-recipe-service
21+
schema = schemathesis.openapi.from_asgi("/openapi.json", app).exclude(
22+
path="/ai/recipes"
23+
)
24+
25+
# Happy path only. Auth rejection (missing/invalid HMAC headers) is covered in test_help_service.py.
26+
schema.config.generation.update(modes=[schemathesis.GenerationMode.POSITIVE])
27+
schema.config.phases.update(phases=["examples", "fuzzing"])
28+
29+
30+
@pytest.fixture(autouse=True)
31+
def _stub_provider_dependencies():
32+
app.dependency_overrides[verify_internal_hmac] = lambda: None
33+
34+
conforming = LocalHelpResponse(
35+
response="Add a pinch of salt to balance the flavour."
36+
)
37+
structured_runnable = AsyncMock()
38+
structured_runnable.ainvoke.return_value = conforming
39+
llm = MagicMock()
40+
llm.with_structured_output.return_value = structured_runnable
41+
app.dependency_overrides[get_llm] = lambda: llm
42+
43+
yield
44+
app.dependency_overrides.clear()
45+
46+
47+
@schema.parametrize()
48+
def test_openapi_conformance(case):
49+
case.call_and_validate()

services/py-recipe-service/dev-requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ pytest
33
pytest-cov
44
httpx
55
pytest-asyncio
6-
pytest-env
6+
pytest-env
7+
schemathesis==4.22.3
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""OpenAPI contract-conformance test for py-recipe-service using Schemathesis.
2+
3+
Verifies that the service's live responses conform to the internal contract in
4+
`api/openapi-internal.yaml`.
5+
"""
6+
7+
import pathlib
8+
from unittest.mock import AsyncMock, MagicMock
9+
10+
import pytest
11+
import schemathesis
12+
13+
from main import app, get_llm, verify_internal_hmac, RecipeListWrapper, LocalRecipeInput
14+
15+
_CONTRACT = (
16+
pathlib.Path(__file__).resolve().parents[3] / "api" / "openapi-internal.yaml"
17+
)
18+
app.openapi_schema = schemathesis.openapi.from_path(_CONTRACT).raw_schema
19+
20+
# /ai/help belongs to py-help-service
21+
schema = schemathesis.openapi.from_asgi("/openapi.json", app).exclude(path="/ai/help")
22+
23+
# Happy path only. Auth rejection (missing/invalid HMAC headers) is covered in test_recipe_service.py.
24+
schema.config.generation.update(modes=[schemathesis.GenerationMode.POSITIVE])
25+
schema.config.phases.update(phases=["examples", "fuzzing"])
26+
27+
28+
@pytest.fixture(autouse=True)
29+
def _stub_provider_dependencies():
30+
app.dependency_overrides[verify_internal_hmac] = lambda: None
31+
32+
conforming = RecipeListWrapper(
33+
recipes=[
34+
LocalRecipeInput(
35+
title="Test Recipe",
36+
ingredients=[{"quantity": 1.0, "unit": "cup", "name": "Flour"}],
37+
instructions=["Mix.", "Bake."],
38+
portions=2.0,
39+
nutrients={"calories": 200, "protein": 5, "fat": 3, "carbs": 35},
40+
)
41+
]
42+
)
43+
structured_runnable = AsyncMock()
44+
structured_runnable.ainvoke.return_value = conforming
45+
llm = MagicMock()
46+
llm.with_structured_output.return_value = structured_runnable
47+
app.dependency_overrides[get_llm] = lambda: llm
48+
49+
yield
50+
app.dependency_overrides.clear()
51+
52+
53+
@schema.parametrize()
54+
def test_openapi_conformance(case):
55+
case.call_and_validate()

services/spring-api/build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,9 @@ dependencies {
104104
testImplementation("org.springframework.boot:spring-boot-webmvc-test")
105105
testImplementation("org.springframework.security:spring-security-test")
106106

107+
// Pact provider-side contract verification (web-client -> spring-api)
108+
testImplementation("au.com.dius.pact.provider:junit5:4.6.17")
109+
107110
// Retrofit
108111
implementation("com.squareup.retrofit2:retrofit:2.11.0")
109112
implementation("com.squareup.retrofit2:converter-jackson:2.11.0")

services/spring-api/src/test/kotlin/org/openapitools/api/RecipesApiTest.kt

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,40 @@ class RecipesApiTest : ApiTestBase() {
239239
.andExpect(jsonPath("$.nutrients.calories").value(400))
240240
}
241241

242+
@Test
243+
fun `recipes put - full payload replaces every field`() {
244+
val token = register()
245+
val id = postRecipe(token)
246+
mockMvc
247+
.perform(
248+
put("/api/v1/recipes/$id")
249+
.header("Authorization", "Bearer $token")
250+
.contentType(MediaType.APPLICATION_JSON)
251+
.content(
252+
"""
253+
{
254+
"title": "Deluxe Pasta",
255+
"ingredients": [
256+
{"quantity": 250, "unit": "g", "name": "spaghetti"},
257+
{"quantity": 3, "unit": "cloves", "name": "garlic"}
258+
],
259+
"instructions": ["Boil", "Fry garlic", "Combine"],
260+
"portions": 1.5,
261+
"nutrients": {"calories": 620, "protein": 18, "fat": 9, "carbs": 95}
262+
}
263+
""".trimIndent(),
264+
),
265+
).andExpect(status().isOk)
266+
.andExpect(jsonPath("$.id").value(id))
267+
.andExpect(jsonPath("$.title").value("Deluxe Pasta"))
268+
.andExpect(jsonPath("$.portions").value(1.5))
269+
.andExpect(jsonPath("$.ingredients.length()").value(2))
270+
.andExpect(jsonPath("$.ingredients[1].name").value("garlic"))
271+
.andExpect(jsonPath("$.instructions.length()").value(3))
272+
.andExpect(jsonPath("$.instructions[2]").value("Combine"))
273+
.andExpect(jsonPath("$.nutrients.carbs").value(95))
274+
}
275+
242276
@Test
243277
fun `recipes put - not found returns 404`() {
244278
val token = register()

0 commit comments

Comments
 (0)