Skip to content

Commit 709dc9e

Browse files
jopemachineclaude
andcommitted
test(BA-5983): cover ModelDefinitionInput merge + to_resolved behavior
Pin the BA-5983 contract: GraphQL/REST inputs accept all-optional fields, but ``to_resolved()`` after the revision merge chain still raises when no source supplies a required field. Three groups: - ``ModelDefinitionInput.to_draft()`` produces a valid empty/partial draft without raising - Empty request merges with variant baseline / preset and resolves to the baseline values (partial overrides also combine correctly) - Missing ``name`` / ``model_path`` / ``port`` / health-check ``path`` with no baseline raises ``ValueError`` at ``to_resolved()`` Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e82cd8f commit 709dc9e

1 file changed

Lines changed: 231 additions & 0 deletions

File tree

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
"""Verify that nullable v2 ``ModelDefinitionInput`` fields still result in
2+
correct required-field enforcement after the revision merge chain.
3+
4+
This pins the BA-5983 behavior: the GraphQL/REST boundary accepts
5+
all-optional fields, but ``to_resolved()`` at the persistence boundary
6+
must still raise when no merge layer (request, preset, variant baseline)
7+
supplies a required value.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import functools
13+
14+
import pytest
15+
16+
from ai.backend.common.config import (
17+
ModelConfigDraft,
18+
ModelDefinitionDraft,
19+
ModelHealthCheckDraft,
20+
ModelServiceConfigDraft,
21+
)
22+
from ai.backend.common.dto.manager.v2.deployment.request import (
23+
ModelConfigInput,
24+
ModelDefinitionInput,
25+
ModelHealthCheckInput,
26+
ModelServiceConfigInput,
27+
)
28+
from ai.backend.manager.data.deployment.types import RevisionDraft
29+
30+
31+
def _merge(*drafts: RevisionDraft) -> RevisionDraft:
32+
return functools.reduce(RevisionDraft.merge, drafts, RevisionDraft())
33+
34+
35+
class TestModelDefinitionInputToDraft:
36+
"""``ModelDefinitionInput.to_draft`` is the bridge between the
37+
all-optional DTO and the merge-chain draft. The conversion itself
38+
must never raise — required-field enforcement is deferred to
39+
``to_resolved()`` after the merge."""
40+
41+
def test_empty_input_yields_empty_draft(self) -> None:
42+
draft = ModelDefinitionInput().to_draft()
43+
assert isinstance(draft, ModelDefinitionDraft)
44+
assert draft.models is None
45+
46+
def test_partial_input_preserves_nones(self) -> None:
47+
draft = ModelDefinitionInput(
48+
models=[ModelConfigInput(name="only-name")],
49+
).to_draft()
50+
assert draft.models is not None
51+
assert draft.models[0].name == "only-name"
52+
assert draft.models[0].model_path is None
53+
54+
def test_nested_service_input_round_trips(self) -> None:
55+
draft = ModelDefinitionInput(
56+
models=[
57+
ModelConfigInput(
58+
name="m",
59+
service=ModelServiceConfigInput(
60+
port=8080,
61+
health_check=ModelHealthCheckInput(path="/healthz"),
62+
),
63+
)
64+
]
65+
).to_draft()
66+
assert draft.models is not None
67+
svc = draft.models[0].service
68+
assert svc is not None
69+
assert svc.port == 8080
70+
assert svc.health_check is not None
71+
assert svc.health_check.path == "/healthz"
72+
73+
74+
class TestEmptyInputMergesWithBaseline:
75+
"""Empty (all-null) request input must let lower-priority sources
76+
(variant baseline, preset) fill the required fields, and the merged
77+
draft must resolve cleanly."""
78+
79+
def test_baseline_fills_required_fields_when_request_is_empty(self) -> None:
80+
variant_baseline = RevisionDraft(
81+
model_definition=ModelDefinitionDraft(
82+
models=[
83+
ModelConfigDraft(name="llama", model_path="/models/llama"),
84+
]
85+
),
86+
)
87+
request = RevisionDraft(model_definition=ModelDefinitionInput().to_draft())
88+
89+
merged = _merge(variant_baseline, request)
90+
91+
assert merged.model_definition is not None
92+
resolved = merged.model_definition.to_resolved()
93+
assert resolved.models[0].name == "llama"
94+
assert resolved.models[0].model_path == "/models/llama"
95+
96+
def test_preset_fills_required_fields_when_request_is_empty(self) -> None:
97+
preset = RevisionDraft(
98+
model_definition=ModelDefinitionDraft(
99+
models=[
100+
ModelConfigDraft(
101+
name="from-preset",
102+
model_path="/preset/path",
103+
service=ModelServiceConfigDraft(
104+
port=9000,
105+
health_check=ModelHealthCheckDraft(path="/ready"),
106+
),
107+
)
108+
]
109+
),
110+
)
111+
request = RevisionDraft(model_definition=ModelDefinitionInput().to_draft())
112+
113+
merged = _merge(preset, request)
114+
115+
assert merged.model_definition is not None
116+
resolved = merged.model_definition.to_resolved()
117+
assert resolved.models[0].name == "from-preset"
118+
assert resolved.models[0].model_path == "/preset/path"
119+
assert resolved.models[0].service is not None
120+
assert resolved.models[0].service.port == 9000
121+
assert resolved.models[0].service.health_check is not None
122+
assert resolved.models[0].service.health_check.path == "/ready"
123+
124+
def test_request_partial_override_combines_with_baseline(self) -> None:
125+
variant_baseline = RevisionDraft(
126+
model_definition=ModelDefinitionDraft(
127+
models=[
128+
ModelConfigDraft(name="baseline-name", model_path="/baseline/path"),
129+
]
130+
),
131+
)
132+
request = RevisionDraft(
133+
model_definition=ModelDefinitionInput(
134+
models=[ModelConfigInput(name="user-name")],
135+
).to_draft(),
136+
)
137+
138+
merged = _merge(variant_baseline, request)
139+
140+
assert merged.model_definition is not None
141+
resolved = merged.model_definition.to_resolved()
142+
assert resolved.models[0].name == "user-name"
143+
assert resolved.models[0].model_path == "/baseline/path"
144+
145+
146+
class TestMergeRaisesWhenAllSourcesAreEmpty:
147+
"""When neither the request nor any baseline source supplies a
148+
required field, ``to_resolved()`` must raise at the persistence
149+
boundary — preserving the pre-BA-5983 contract."""
150+
151+
def test_missing_model_name_raises(self) -> None:
152+
request = RevisionDraft(
153+
model_definition=ModelDefinitionInput(
154+
models=[ModelConfigInput(model_path="/p")],
155+
).to_draft(),
156+
)
157+
158+
merged = _merge(request)
159+
160+
assert merged.model_definition is not None
161+
with pytest.raises(ValueError, match=r"ModelConfig\.name is required"):
162+
merged.model_definition.to_resolved()
163+
164+
def test_missing_model_path_raises(self) -> None:
165+
request = RevisionDraft(
166+
model_definition=ModelDefinitionInput(
167+
models=[ModelConfigInput(name="n")],
168+
).to_draft(),
169+
)
170+
171+
merged = _merge(request)
172+
173+
assert merged.model_definition is not None
174+
with pytest.raises(ValueError, match=r"ModelConfig\.model_path is required"):
175+
merged.model_definition.to_resolved()
176+
177+
def test_missing_service_port_raises(self) -> None:
178+
request = RevisionDraft(
179+
model_definition=ModelDefinitionInput(
180+
models=[
181+
ModelConfigInput(
182+
name="n",
183+
model_path="/p",
184+
service=ModelServiceConfigInput(),
185+
)
186+
],
187+
).to_draft(),
188+
)
189+
190+
merged = _merge(request)
191+
192+
assert merged.model_definition is not None
193+
with pytest.raises(ValueError, match=r"ModelServiceConfig\.port is required"):
194+
merged.model_definition.to_resolved()
195+
196+
def test_missing_health_check_path_raises(self) -> None:
197+
request = RevisionDraft(
198+
model_definition=ModelDefinitionInput(
199+
models=[
200+
ModelConfigInput(
201+
name="n",
202+
model_path="/p",
203+
service=ModelServiceConfigInput(
204+
port=8080,
205+
health_check=ModelHealthCheckInput(),
206+
),
207+
)
208+
],
209+
).to_draft(),
210+
)
211+
212+
merged = _merge(request)
213+
214+
assert merged.model_definition is not None
215+
with pytest.raises(ValueError, match=r"ModelHealthCheck\.path is required"):
216+
merged.model_definition.to_resolved()
217+
218+
def test_empty_request_with_no_baseline_yields_empty_resolved(self) -> None:
219+
"""A completely empty merge chain resolves to an empty ModelDefinition.
220+
221+
The ``add_revision`` controller guards against this case separately
222+
(``model_definition.models must contain at least one entry``); the
223+
resolved type itself permits an empty models list.
224+
"""
225+
request = RevisionDraft(model_definition=ModelDefinitionInput().to_draft())
226+
227+
merged = _merge(request)
228+
229+
assert merged.model_definition is not None
230+
resolved = merged.model_definition.to_resolved()
231+
assert resolved.models == []

0 commit comments

Comments
 (0)