From eed61a298b3f1babe50475dd58c74d83a25b26c7 Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Wed, 17 Jun 2026 11:47:28 -0400 Subject: [PATCH] Handle empty featured_image from Columbia feed Products with no featured image return featured_image: [] (empty array) rather than an object or null, which failed to bind to a single ColumbiaImage and aborted the whole sync. Adds ColumbiaImageJsonConverter that reads the object when present and yields null for any non-object form ([], false, ""), and drops the unused GalleryImages property (we only use featured_image) to remove the same risk. Regression tests cover both the empty-array and object cases. Co-Authored-By: Claude Opus 4.8 --- .../Columbia/ColumbiaImageJsonConverter.cs | 49 +++++++++++++++++++ .../DTOs/Columbia/ColumbiaProductDtos.cs | 1 - .../Services/ColumbiaCoatingsApiClient.cs | 1 + .../ColumbiaCatalogMapperTests.cs | 31 ++++++++++++ 4 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 src/PowderCoating.Application/DTOs/Columbia/ColumbiaImageJsonConverter.cs diff --git a/src/PowderCoating.Application/DTOs/Columbia/ColumbiaImageJsonConverter.cs b/src/PowderCoating.Application/DTOs/Columbia/ColumbiaImageJsonConverter.cs new file mode 100644 index 0000000..1ee746d --- /dev/null +++ b/src/PowderCoating.Application/DTOs/Columbia/ColumbiaImageJsonConverter.cs @@ -0,0 +1,49 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace PowderCoating.Application.DTOs.Columbia; + +/// +/// Tolerant converter for Columbia image fields. WordPress/WooCommerce returns an object +/// ({id,src,name,alt}) when an image is present, but an empty array ([]) — or +/// sometimes false/empty string — when it is not. A single +/// can't bind to those non-object forms, so this converter reads the object when present and +/// yields null for anything else (consuming the token so deserialization continues). +/// +public class ColumbiaImageJsonConverter : JsonConverter +{ + public override ColumbiaImage? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + switch (reader.TokenType) + { + case JsonTokenType.StartObject: + using (var doc = JsonDocument.ParseValue(ref reader)) + { + var el = doc.RootElement; + return new ColumbiaImage + { + Id = el.TryGetProperty("id", out var id) && id.TryGetInt32(out var i) ? i : 0, + Src = GetString(el, "src"), + Name = GetString(el, "name"), + Alt = GetString(el, "alt"), + }; + } + + case JsonTokenType.StartArray: + reader.Skip(); // empty/non-empty array form means "no image" + return null; + + default: + // Primitive (false / "" / null / number): nothing to consume further. + return null; + } + } + + public override void Write(Utf8JsonWriter writer, ColumbiaImage? value, JsonSerializerOptions options) + => throw new NotSupportedException("Columbia image fields are read-only."); + + private static string GetString(JsonElement el, string property) => + el.TryGetProperty(property, out var v) && v.ValueKind == JsonValueKind.String + ? v.GetString() ?? string.Empty + : string.Empty; +} diff --git a/src/PowderCoating.Application/DTOs/Columbia/ColumbiaProductDtos.cs b/src/PowderCoating.Application/DTOs/Columbia/ColumbiaProductDtos.cs index 10e8c75..7612e72 100644 --- a/src/PowderCoating.Application/DTOs/Columbia/ColumbiaProductDtos.cs +++ b/src/PowderCoating.Application/DTOs/Columbia/ColumbiaProductDtos.cs @@ -89,7 +89,6 @@ public class ColumbiaProduct public string ShortDescription { get; set; } = string.Empty; public ColumbiaImage? FeaturedImage { get; set; } - public List GalleryImages { get; set; } = new(); // ── Taxonomy (arrays of {name}) — used at import to derive manufacturer/category, not stored raw ── public List Categories { get; set; } = new(); diff --git a/src/PowderCoating.Infrastructure/Services/ColumbiaCoatingsApiClient.cs b/src/PowderCoating.Infrastructure/Services/ColumbiaCoatingsApiClient.cs index 3ea51b7..36c317c 100644 --- a/src/PowderCoating.Infrastructure/Services/ColumbiaCoatingsApiClient.cs +++ b/src/PowderCoating.Infrastructure/Services/ColumbiaCoatingsApiClient.cs @@ -32,6 +32,7 @@ public class ColumbiaCoatingsApiClient : IColumbiaCoatingsApiClient { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, PropertyNameCaseInsensitive = true, + Converters = { new ColumbiaImageJsonConverter() }, }; public ColumbiaCoatingsApiClient( diff --git a/tests/PowderCoating.UnitTests/ColumbiaCatalogMapperTests.cs b/tests/PowderCoating.UnitTests/ColumbiaCatalogMapperTests.cs index b04da4b..7bf96ad 100644 --- a/tests/PowderCoating.UnitTests/ColumbiaCatalogMapperTests.cs +++ b/tests/PowderCoating.UnitTests/ColumbiaCatalogMapperTests.cs @@ -230,6 +230,37 @@ public class ColumbiaCatalogMapperTests // ── End-to-end mapping invariants ───────────────────────────────────── + // ── Tolerant image deserialization (WordPress returns [] / false when empty) ── + + private static readonly JsonSerializerOptions ClientJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + PropertyNameCaseInsensitive = true, + Converters = { new ColumbiaImageJsonConverter() }, + }; + + [Fact] + public void Deserialize_FeaturedImageEmptyArray_YieldsNullImage() + { + // WordPress returns featured_image: [] for products with no image. + var json = """{ "sku": "X1", "name": "No Image", "featured_image": [] }"""; + var product = JsonSerializer.Deserialize(json, ClientJsonOptions); + + Assert.NotNull(product); + Assert.Null(product!.FeaturedImage); + Assert.Null(ColumbiaCatalogMapper.Map(product).ImageUrl); + } + + [Fact] + public void Deserialize_FeaturedImageObject_ParsesSrc() + { + var json = """{ "sku": "X1", "name": "Has Image", "featured_image": { "id": 5, "src": "https://x/img.png", "name": "i", "alt": "" } }"""; + var product = JsonSerializer.Deserialize(json, ClientJsonOptions); + + Assert.Equal("https://x/img.png", product!.FeaturedImage!.Src); + Assert.Equal("https://x/img.png", ColumbiaCatalogMapper.Map(product).ImageUrl); + } + [Fact] public void Map_AlwaysStampsSource_AndLeavesEnrichmentFieldsNull() {