Make PriceCharting runs auditable and pin matches by product id
Two changes aimed at the first real run, since the integration cannot be exercised here without a subscription. Refresh now reports which product each game matched — name, console and the source's id — next to the prices, and dry-run surfaces it before anything is written. This is the failure that would otherwise go unnoticed: a lookup for the DS "Chrono Trigger" resolving to the SNES original returns entirely plausible numbers for the wrong game, and nothing in a bare price would say so. The matched id is then stored on the game, and later refreshes look it up directly instead of repeating the title search. Cheaper, and stable — a search that drifts to a different edition next month cannot silently re-price something that was already matched correctly. IPriceProvider takes an optional sourceId so this stays provider-agnostic. eBay ignores it, having no stable per-product identifier in Browse. 139 backend tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -30,7 +30,11 @@ public record PriceRefreshRequest
|
||||
public record PriceRefreshItem(
|
||||
int GameId, string Title,
|
||||
decimal? Loose, decimal? Cib, decimal? New,
|
||||
int Samples, int Discarded, string? Error);
|
||||
int Samples, int Discarded, string? Error,
|
||||
// What the source says it priced. A DS entry that quietly resolves to the
|
||||
// SNES original returns plausible numbers for the wrong game, so a dry run
|
||||
// has to show this before anything is written.
|
||||
string? MatchedName = null, string? MatchedConsole = null, string? SourceId = null);
|
||||
|
||||
public record PriceRefreshResult(
|
||||
bool DryRun, string Source, int Considered, int Priced, int Failed,
|
||||
@@ -122,26 +126,32 @@ public class PricesController(
|
||||
{
|
||||
try
|
||||
{
|
||||
var estimate = await provider.EstimateAsync(game.Title, game.System, ct);
|
||||
// Reuse a previous match where there is one, so refreshes stay
|
||||
// pinned to the same product instead of re-running a search.
|
||||
var estimate = await provider.EstimateAsync(
|
||||
game.Title, game.System,
|
||||
provider.Name == game.MarketValueSource ? game.PriceSourceId : null, ct);
|
||||
|
||||
if (!estimate.HasAnyPrice)
|
||||
{
|
||||
failed++;
|
||||
items.Add(new PriceRefreshItem(game.Id, game.Title, null, null, null,
|
||||
0, estimate.Discarded, "No usable listings found"));
|
||||
0, estimate.Discarded, "No price found for this title"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!request.DryRun)
|
||||
{
|
||||
ApplyEstimate(game, estimate.Loose, estimate.Cib, estimate.New, provider.Name);
|
||||
game.PriceSourceId = estimate.SourceId ?? game.PriceSourceId;
|
||||
}
|
||||
|
||||
priced++;
|
||||
items.Add(new PriceRefreshItem(game.Id, game.Title,
|
||||
estimate.Loose, estimate.Cib, estimate.New,
|
||||
estimate.LooseSamples + estimate.CibSamples + estimate.NewSamples,
|
||||
estimate.Discarded, null));
|
||||
estimate.Discarded, null,
|
||||
estimate.MatchedName, estimate.MatchedConsole, estimate.SourceId));
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
|
||||
+410
@@ -0,0 +1,410 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using LudosData.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace LudosData.Api.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(LudosDbContext))]
|
||||
[Migration("20260804205124_AddPriceSourceId")]
|
||||
partial class AddPriceSourceId
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
|
||||
|
||||
modelBuilder.Entity("LudosData.Api.Domain.AppUser", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Art")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("FirstName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LastName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex");
|
||||
|
||||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LudosData.Api.Domain.Game", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Art")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Condition")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Developer")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Dumped")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Finished")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("MarketValue")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("MarketValueSource")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset?>("MarketValueUpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Own")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("OwnerId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Played")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PriceSourceId")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Publisher")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly?>("PurchaseDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("PurchasePrice")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("Rating")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Region")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("System")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("ValueCib")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("ValueLoose")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("ValueNew")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Year")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OwnerId", "Genre");
|
||||
|
||||
b.HasIndex("OwnerId", "Rating");
|
||||
|
||||
b.HasIndex("OwnerId", "System");
|
||||
|
||||
b.HasIndex("OwnerId", "Title");
|
||||
|
||||
b.ToTable("Games");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex");
|
||||
|
||||
b.ToTable("AspNetRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetRoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetUserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LudosData.Api.Domain.Game", b =>
|
||||
{
|
||||
b.HasOne("LudosData.Api.Domain.AppUser", "Owner")
|
||||
.WithMany("Games")
|
||||
.HasForeignKey("OwnerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Owner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("LudosData.Api.Domain.AppUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.HasOne("LudosData.Api.Domain.AppUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("LudosData.Api.Domain.AppUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.HasOne("LudosData.Api.Domain.AppUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LudosData.Api.Domain.AppUser", b =>
|
||||
{
|
||||
b.Navigation("Games");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace LudosData.Api.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPriceSourceId : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PriceSourceId",
|
||||
table: "Games",
|
||||
type: "TEXT",
|
||||
maxLength: 100,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PriceSourceId",
|
||||
table: "Games");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,6 +149,10 @@ namespace LudosData.Api.Data.Migrations
|
||||
b.Property<bool>("Played")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PriceSourceId")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Publisher")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@@ -94,6 +94,15 @@ public class Game
|
||||
// re-prices it without another lookup, and the dashboard can answer both
|
||||
// "what is this worth" and "what would it be worth complete".
|
||||
|
||||
/// <summary>
|
||||
/// The price source's identifier for this game, kept after the first match.
|
||||
/// Later refreshes look it up directly instead of repeating a fuzzy search,
|
||||
/// which makes them both cheaper and stable — a title search that drifts to
|
||||
/// a different edition next month would silently re-price the wrong thing.
|
||||
/// </summary>
|
||||
[MaxLength(100)]
|
||||
public string? PriceSourceId { get; set; }
|
||||
|
||||
public decimal? ValueLoose { get; set; }
|
||||
public decimal? ValueCib { get; set; }
|
||||
public decimal? ValueNew { get; set; }
|
||||
|
||||
@@ -35,7 +35,14 @@ public interface IPriceProvider
|
||||
{
|
||||
string Name { get; }
|
||||
bool IsConfigured { get; }
|
||||
Task<PriceEstimate> EstimateAsync(string title, string? system, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Prices a game. <paramref name="sourceId"/> is this provider's own
|
||||
/// identifier from a previous match, when one is known — providers that can
|
||||
/// use it should, since an exact lookup beats re-running a title search.
|
||||
/// </summary>
|
||||
Task<PriceEstimate> EstimateAsync(
|
||||
string title, string? system, string? sourceId = null, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -94,8 +101,12 @@ public class EbayPriceProvider(
|
||||
};
|
||||
|
||||
public async Task<PriceEstimate> EstimateAsync(
|
||||
string title, string? system, CancellationToken ct = default)
|
||||
string title, string? system, string? sourceId = null, CancellationToken ct = default)
|
||||
{
|
||||
// Browse has no stable per-product identifier to reuse; every lookup is
|
||||
// a fresh search.
|
||||
_ = sourceId;
|
||||
|
||||
if (!IsConfigured)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
|
||||
@@ -70,7 +70,7 @@ public class PriceChartingProvider(
|
||||
}
|
||||
|
||||
public async Task<PriceEstimate> EstimateAsync(
|
||||
string title, string? system, CancellationToken ct = default)
|
||||
string title, string? system, string? sourceId = null, CancellationToken ct = default)
|
||||
{
|
||||
if (!IsConfigured)
|
||||
{
|
||||
@@ -79,9 +79,16 @@ public class PriceChartingProvider(
|
||||
}
|
||||
|
||||
var client = httpClientFactory.CreateClient("pricecharting");
|
||||
|
||||
// An id from a previous match identifies the exact product; only fall
|
||||
// back to searching by name when there is none.
|
||||
var lookup = string.IsNullOrWhiteSpace(sourceId)
|
||||
? $"&q={Uri.EscapeDataString(BuildQuery(title, system))}"
|
||||
: $"&id={Uri.EscapeDataString(sourceId)}";
|
||||
|
||||
var url = "https://www.pricecharting.com/api/product"
|
||||
+ $"?t={Uri.EscapeDataString(_options.Token)}"
|
||||
+ $"&q={Uri.EscapeDataString(BuildQuery(title, system))}";
|
||||
+ lookup;
|
||||
|
||||
using var response = await client.GetAsync(url, ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
@@ -130,7 +137,33 @@ public class PriceChartingProvider(
|
||||
loose is null ? 0 : 1,
|
||||
cib is null ? 0 : 1,
|
||||
boxed is null ? 0 : 1,
|
||||
0);
|
||||
0)
|
||||
{
|
||||
// Carried through so a dry run can show which record was matched.
|
||||
MatchedName = ReadString(root, "product-name", "productName"),
|
||||
MatchedConsole = ReadString(root, "console-name", "consoleName"),
|
||||
SourceId = ReadString(root, "id", "productId"),
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ReadString(JsonElement root, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (root.TryGetProperty(name, out var element))
|
||||
{
|
||||
var value = element.ValueKind == JsonValueKind.String
|
||||
? element.GetString()
|
||||
: element.ToString();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Prices arrive as integer pennies; 1250 means $12.50.</summary>
|
||||
|
||||
@@ -21,6 +21,25 @@ public record PriceEstimate(
|
||||
int NewSamples,
|
||||
int Discarded)
|
||||
{
|
||||
/// <summary>
|
||||
/// What the source thinks it priced, when it says so.
|
||||
///
|
||||
/// Prices are meaningless without knowing which record they came from: a
|
||||
/// lookup for the DS "Chrono Trigger" that quietly resolves to the SNES
|
||||
/// original returns plausible numbers for the wrong game. Surfacing the
|
||||
/// matched title and console makes a dry run auditable instead of a leap of
|
||||
/// faith.
|
||||
/// </summary>
|
||||
public string? MatchedName { get; init; }
|
||||
public string? MatchedConsole { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The source's own identifier for the matched product, when it has one.
|
||||
/// Storing it turns every later refresh into an exact lookup rather than a
|
||||
/// repeat of the same fuzzy search.
|
||||
/// </summary>
|
||||
public string? SourceId { get; init; }
|
||||
|
||||
public bool HasAnyPrice => Loose is not null || Cib is not null || New is not null;
|
||||
|
||||
public static readonly PriceEstimate Empty = new(null, null, null, 0, 0, 0, 0);
|
||||
|
||||
@@ -367,3 +367,60 @@ public class PriceEndpointTests(LudosApiFactory factory) : IClassFixture<LudosAp
|
||||
decimal? ValueLoose, decimal? ValueCib, decimal? ValueNew);
|
||||
private record PagePayload(List<GamePayload> Items, int Total);
|
||||
}
|
||||
|
||||
public class PriceChartingMatchTests
|
||||
{
|
||||
[Fact]
|
||||
public void The_matched_product_and_console_are_reported()
|
||||
{
|
||||
using var document = JsonDocument.Parse("""
|
||||
{
|
||||
"status": "success",
|
||||
"id": "6910",
|
||||
"product-name": "Chrono Trigger",
|
||||
"console-name": "Super Nintendo",
|
||||
"loose-price": 12800, "cib-price": 65000, "new-price": 1200000
|
||||
}
|
||||
""");
|
||||
|
||||
var estimate = PriceChartingProvider.Parse(document);
|
||||
|
||||
// Without this a dry run cannot tell a DS entry that resolved to the
|
||||
// SNES original from one that resolved correctly.
|
||||
Assert.Equal("Chrono Trigger", estimate.MatchedName);
|
||||
Assert.Equal("Super Nintendo", estimate.MatchedConsole);
|
||||
Assert.Equal("6910", estimate.SourceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_numeric_id_is_read_as_a_string()
|
||||
{
|
||||
using var document = JsonDocument.Parse("""
|
||||
{ "status": "success", "id": 6910, "loose-price": 100 }
|
||||
""");
|
||||
|
||||
Assert.Equal("6910", PriceChartingProvider.Parse(document).SourceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_match_metadata_is_not_fatal()
|
||||
{
|
||||
using var document = JsonDocument.Parse("""{ "loose-price": 12800 }""");
|
||||
|
||||
var estimate = PriceChartingProvider.Parse(document);
|
||||
|
||||
Assert.Equal(128.00m, estimate.Loose);
|
||||
Assert.Null(estimate.MatchedName);
|
||||
Assert.Null(estimate.SourceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_stored_id_is_preferred_over_a_title_search()
|
||||
{
|
||||
// Documents the intent of the lookup switch: with an id, the query is an
|
||||
// exact product fetch, so a drifting title search cannot re-price a
|
||||
// different edition on a later run.
|
||||
Assert.Equal("super nintendo Chrono Trigger",
|
||||
PriceChartingProvider.BuildQuery("Chrono Trigger", "SNES"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user