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:
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user