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 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 11:47:28 -04:00
parent 2286b5431d
commit eed61a298b
4 changed files with 81 additions and 1 deletions
@@ -0,0 +1,49 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace PowderCoating.Application.DTOs.Columbia;
/// <summary>
/// Tolerant converter for Columbia image fields. WordPress/WooCommerce returns an object
/// (<c>{id,src,name,alt}</c>) when an image is present, but an empty array (<c>[]</c>) — or
/// sometimes <c>false</c>/empty string — when it is not. A single <see cref="ColumbiaImage"/>
/// 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).
/// </summary>
public class ColumbiaImageJsonConverter : JsonConverter<ColumbiaImage?>
{
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;
}
@@ -89,7 +89,6 @@ public class ColumbiaProduct
public string ShortDescription { get; set; } = string.Empty;
public ColumbiaImage? FeaturedImage { get; set; }
public List<ColumbiaImage> GalleryImages { get; set; } = new();
// ── Taxonomy (arrays of {name}) — used at import to derive manufacturer/category, not stored raw ──
public List<ColumbiaNamed> Categories { get; set; } = new();
@@ -32,6 +32,7 @@ public class ColumbiaCoatingsApiClient : IColumbiaCoatingsApiClient
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
PropertyNameCaseInsensitive = true,
Converters = { new ColumbiaImageJsonConverter() },
};
public ColumbiaCoatingsApiClient(
@@ -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<ColumbiaProduct>(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<ColumbiaProduct>(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()
{