@@ -179,6 +179,42 @@ def test_uses_creatives_key(self):
179179 assert "results" not in result
180180 assert result ["creatives" ] == creatives
181181
182+ def test_strips_none_from_sync_result_fields (self ):
183+ """None-valued fields in sync creative dicts are stripped from wire output."""
184+ creatives = [{"creative_id" : "c1" , "action" : "created" , "status" : None }]
185+ result = sync_creatives_response (creatives )
186+ assert result ["creatives" ][0 ] == {"creative_id" : "c1" , "action" : "created" }
187+
188+
189+ class TestStripNoneValues :
190+ """_strip_none_values removes None-valued keys from dicts recursively."""
191+
192+ def test_flat_dict_strips_none (self ):
193+ from adcp .server .responses import _strip_none_values
194+
195+ result = _strip_none_values ({"a" : "hello" , "b" : None , "c" : 42 })
196+ assert result == {"a" : "hello" , "c" : 42 }
197+
198+ def test_nested_dict_strips_none (self ):
199+ from adcp .server .responses import _strip_none_values
200+
201+ result = _strip_none_values (
202+ {"outer" : {"inner" : None , "keep" : "yes" }, "top_none" : None }
203+ )
204+ assert result == {"outer" : {"keep" : "yes" }}
205+
206+ def test_list_items_stripped (self ):
207+ from adcp .server .responses import _strip_none_values
208+
209+ result = _strip_none_values ([{"x" : None , "y" : 1 }, {"x" : 2 , "y" : None }])
210+ assert result == [{"y" : 1 }, {"x" : 2 }]
211+
212+ def test_non_none_values_preserved (self ):
213+ from adcp .server .responses import _strip_none_values
214+
215+ result = _strip_none_values ({"a" : 0 , "b" : False , "c" : "" , "d" : []})
216+ assert result == {"a" : 0 , "b" : False , "c" : "" , "d" : []}
217+
182218
183219class TestListCreativesResponse :
184220 def test_basic (self ):
@@ -225,6 +261,68 @@ def test_preserves_caller_provided_timestamps(self):
225261 assert item ["created_date" ] == created
226262 assert item ["updated_date" ] == updated
227263
264+ def test_strips_none_from_asset_fields_in_dict_creatives (self ):
265+ """None-valued asset fields must not appear as null on the wire.
266+
267+ ImageAsset.format / alt_text / provenance are optional (non-required)
268+ in the JSON schema but non-nullable (type: string, not [string, null]).
269+ When an adopter builds a dict-based creative with None-valued asset
270+ fields, the response builder must strip them before wire serialisation
271+ so the storyboard schema validator does not reject the payload.
272+ """
273+ creative = {
274+ "creative_id" : "c1" ,
275+ "created_date" : "2024-01-01T00:00:00+00:00" ,
276+ "updated_date" : "2024-01-01T00:00:00+00:00" ,
277+ "assets" : {
278+ "banner" : {
279+ "asset_type" : "image" ,
280+ "url" : "https://cdn.example.com/banner.png" ,
281+ "width" : 300 ,
282+ "height" : 250 ,
283+ "format" : None ,
284+ "alt_text" : None ,
285+ "provenance" : None ,
286+ }
287+ },
288+ }
289+ result = list_creatives_response ([creative ])
290+ asset = result ["creatives" ][0 ]["assets" ]["banner" ]
291+ assert "format" not in asset , "format: null must be stripped from wire output"
292+ assert "alt_text" not in asset , "alt_text: null must be stripped from wire output"
293+ assert "provenance" not in asset , "provenance: null must be stripped from wire output"
294+ assert asset ["asset_type" ] == "image"
295+ assert asset ["url" ] == "https://cdn.example.com/banner.png"
296+ assert asset ["width" ] == 300
297+ assert asset ["height" ] == 250
298+
299+ def test_strips_none_from_video_asset_fields (self ):
300+ """VideoAsset optional fields (container_format, video_codec, etc.) are non-nullable."""
301+ creative = {
302+ "creative_id" : "v1" ,
303+ "created_date" : "2024-01-01T00:00:00+00:00" ,
304+ "updated_date" : "2024-01-01T00:00:00+00:00" ,
305+ "assets" : {
306+ "main_video" : {
307+ "asset_type" : "video" ,
308+ "url" : "https://cdn.example.com/video.mp4" ,
309+ "width" : 1920 ,
310+ "height" : 1080 ,
311+ "container_format" : None ,
312+ "video_codec" : None ,
313+ "duration_ms" : None ,
314+ "provenance" : None ,
315+ }
316+ },
317+ }
318+ result = list_creatives_response ([creative ])
319+ asset = result ["creatives" ][0 ]["assets" ]["main_video" ]
320+ assert "container_format" not in asset
321+ assert "video_codec" not in asset
322+ assert "duration_ms" not in asset
323+ assert "provenance" not in asset
324+ assert asset ["asset_type" ] == "video"
325+
228326
229327class TestPreviewCreativeResponse :
230328 def test_basic (self ):
@@ -234,6 +332,32 @@ def test_basic(self):
234332 assert result ["previews" ] == previews
235333 assert "expires_at" in result
236334
335+ def test_strips_none_from_asset_fields_in_preview (self ):
336+ """None asset fields in preview input are stripped from wire output."""
337+ previews = [
338+ {
339+ "preview_id" : "p1" ,
340+ "input" : {
341+ "assets" : {
342+ "hero" : {
343+ "asset_type" : "image" ,
344+ "url" : "https://cdn.example.com/hero.png" ,
345+ "width" : 1200 ,
346+ "height" : 628 ,
347+ "alt_text" : None ,
348+ "format" : None ,
349+ }
350+ }
351+ },
352+ "renders" : [],
353+ }
354+ ]
355+ result = preview_creative_response (previews )
356+ asset = result ["previews" ][0 ]["input" ]["assets" ]["hero" ]
357+ assert "alt_text" not in asset
358+ assert "format" not in asset
359+ assert asset ["asset_type" ] == "image"
360+
237361
238362class TestBuildCreativeResponse :
239363 def test_basic (self ):
@@ -242,6 +366,48 @@ def test_basic(self):
242366 assert result ["creative_manifest" ] == manifest
243367 assert result ["sandbox" ] is True
244368
369+ def test_strips_none_from_asset_fields_in_manifest (self ):
370+ """None asset fields in build_creative manifest are stripped from wire output."""
371+ manifest = {
372+ "format_id" : {"agent_url" : "http://localhost" , "id" : "d300" },
373+ "name" : "Test" ,
374+ "assets" : {
375+ "banner" : {
376+ "asset_type" : "image" ,
377+ "url" : "https://cdn.example.com/banner.png" ,
378+ "width" : 300 ,
379+ "height" : 250 ,
380+ "format" : None ,
381+ "alt_text" : None ,
382+ }
383+ },
384+ }
385+ result = build_creative_response (manifest )
386+ asset = result ["creative_manifest" ]["assets" ]["banner" ]
387+ assert "format" not in asset
388+ assert "alt_text" not in asset
389+ assert asset ["url" ] == "https://cdn.example.com/banner.png"
390+
391+ def test_strips_none_from_multi_manifest (self ):
392+ """None stripping works for multi-manifest (list) variant."""
393+ manifests = [
394+ {
395+ "name" : "A" ,
396+ "assets" : {
397+ "img" : {
398+ "asset_type" : "image" ,
399+ "url" : "u" ,
400+ "width" : 1 ,
401+ "height" : 1 ,
402+ "format" : None ,
403+ }
404+ },
405+ }
406+ ]
407+ result = build_creative_response (manifests )
408+ asset = result ["creative_manifests" ][0 ]["assets" ]["img" ]
409+ assert "format" not in asset
410+
245411
246412class TestSignalsResponse :
247413 def test_basic (self ):
0 commit comments