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 string ShortDescription { get; set; } = string.Empty;
|
||||||
|
|
||||||
public ColumbiaImage? FeaturedImage { get; set; }
|
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 ──
|
// ── Taxonomy (arrays of {name}) — used at import to derive manufacturer/category, not stored raw ──
|
||||||
public List<ColumbiaNamed> Categories { get; set; } = new();
|
public List<ColumbiaNamed> Categories { get; set; } = new();
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ public class ColumbiaCoatingsApiClient : IColumbiaCoatingsApiClient
|
|||||||
{
|
{
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||||
PropertyNameCaseInsensitive = true,
|
PropertyNameCaseInsensitive = true,
|
||||||
|
Converters = { new ColumbiaImageJsonConverter() },
|
||||||
};
|
};
|
||||||
|
|
||||||
public ColumbiaCoatingsApiClient(
|
public ColumbiaCoatingsApiClient(
|
||||||
|
|||||||
@@ -230,6 +230,37 @@ public class ColumbiaCatalogMapperTests
|
|||||||
|
|
||||||
// ── End-to-end mapping invariants ─────────────────────────────────────
|
// ── 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]
|
[Fact]
|
||||||
public void Map_AlwaysStampsSource_AndLeavesEnrichmentFieldsNull()
|
public void Map_AlwaysStampsSource_AndLeavesEnrichmentFieldsNull()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user