diff --git a/README.md b/README.md
index 401315b..699d754 100644
--- a/README.md
+++ b/README.md
@@ -263,6 +263,34 @@ that copy's condition — so changing a condition re-prices it with no further
lookup. Every value carries the source that wrote it and the moment it was
captured.
+**Using PriceCharting.** Subscribe, take the token from the Subscriptions page
+("API/Download"), and set `PRICECHARTING_TOKEN` in `.env`. Their API access
+comes with a paid subscription; the bulk CSV download is limited to their top
+tier, so check which tier you need before subscribing — this integration does
+per-game lookups and only needs the API.
+
+Run a dry run first:
+
+```bash
+curl -X POST localhost:8080/api/prices/refresh \
+ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
+ -d '{"provider":"pricecharting","dryRun":true,"limit":10}'
+```
+
+The response reports **which product each game matched** — name, console and id
+— alongside the prices. That matters more than it sounds: a lookup for the DS
+"Chrono Trigger" that quietly resolves to the SNES original returns entirely
+plausible numbers for the wrong game. Check the matches, then run without
+`dryRun`.
+
+Once a game is priced, the matched product id is stored and later refreshes look
+it up directly, so they are cheaper and cannot drift to a different edition.
+
+Their published API docs are not reachable without an account, so the response
+parser follows the widely-used convention — integer pennies under hyphenated
+keys — and is tolerant enough that a naming mismatch reads as "no price" rather
+than throwing. `PriceChartingProvider.Parse` is the one place to adjust.
+
### Database changes
```bash
diff --git a/backend/src/LudosData.Api/Controllers/PricesController.cs b/backend/src/LudosData.Api/Controllers/PricesController.cs
index d665e12..1f8cc1a 100644
--- a/backend/src/LudosData.Api/Controllers/PricesController.cs
+++ b/backend/src/LudosData.Api/Controllers/PricesController.cs
@@ -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)
{
diff --git a/backend/src/LudosData.Api/Data/Migrations/20260804205124_AddPriceSourceId.Designer.cs b/backend/src/LudosData.Api/Data/Migrations/20260804205124_AddPriceSourceId.Designer.cs
new file mode 100644
index 0000000..7068270
--- /dev/null
+++ b/backend/src/LudosData.Api/Data/Migrations/20260804205124_AddPriceSourceId.Designer.cs
@@ -0,0 +1,410 @@
+//
+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
+ {
+ ///
+ 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("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("AccessFailedCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("Art")
+ .HasColumnType("TEXT");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Email")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("EmailConfirmed")
+ .HasColumnType("INTEGER");
+
+ b.Property("FirstName")
+ .HasColumnType("TEXT");
+
+ b.Property("LastName")
+ .HasColumnType("TEXT");
+
+ b.Property("LockoutEnabled")
+ .HasColumnType("INTEGER");
+
+ b.Property("LockoutEnd")
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedEmail")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedUserName")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("PasswordHash")
+ .HasColumnType("TEXT");
+
+ b.Property("PhoneNumber")
+ .HasColumnType("TEXT");
+
+ b.Property("PhoneNumberConfirmed")
+ .HasColumnType("INTEGER");
+
+ b.Property("SecurityStamp")
+ .HasColumnType("TEXT");
+
+ b.Property("TwoFactorEnabled")
+ .HasColumnType("INTEGER");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("Art")
+ .HasMaxLength(200)
+ .HasColumnType("TEXT");
+
+ b.Property("Condition")
+ .HasColumnType("INTEGER");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Description")
+ .HasColumnType("TEXT");
+
+ b.Property("Developer")
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("Dumped")
+ .HasColumnType("INTEGER");
+
+ b.Property("Finished")
+ .HasColumnType("INTEGER");
+
+ b.Property("Genre")
+ .HasMaxLength(50)
+ .HasColumnType("TEXT");
+
+ b.Property("MarketValue")
+ .HasColumnType("INTEGER");
+
+ b.Property("MarketValueSource")
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("MarketValueUpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Notes")
+ .HasColumnType("TEXT");
+
+ b.Property("Own")
+ .HasColumnType("INTEGER");
+
+ b.Property("OwnerId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Played")
+ .HasColumnType("INTEGER");
+
+ b.Property("PriceSourceId")
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("Publisher")
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("PurchaseDate")
+ .HasColumnType("TEXT");
+
+ b.Property("PurchasePrice")
+ .HasColumnType("INTEGER");
+
+ b.Property("Rating")
+ .HasColumnType("INTEGER");
+
+ b.Property("Region")
+ .HasColumnType("INTEGER");
+
+ b.Property("System")
+ .HasMaxLength(50)
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("ValueCib")
+ .HasColumnType("INTEGER");
+
+ b.Property("ValueLoose")
+ .HasColumnType("INTEGER");
+
+ b.Property("ValueNew")
+ .HasColumnType("INTEGER");
+
+ b.Property("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("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("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", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("ClaimType")
+ .HasColumnType("TEXT");
+
+ b.Property("ClaimValue")
+ .HasColumnType("TEXT");
+
+ b.Property("RoleId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetRoleClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("ClaimType")
+ .HasColumnType("TEXT");
+
+ b.Property("ClaimValue")
+ .HasColumnType("TEXT");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.Property("LoginProvider")
+ .HasColumnType("TEXT");
+
+ b.Property("ProviderKey")
+ .HasColumnType("TEXT");
+
+ b.Property("ProviderDisplayName")
+ .HasColumnType("TEXT");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("LoginProvider", "ProviderKey");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserLogins", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property("RoleId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("UserId", "RoleId");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetUserRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property("LoginProvider")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.Property("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", b =>
+ {
+ b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.HasOne("LudosData.Api.Domain.AppUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.HasOne("LudosData.Api.Domain.AppUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", 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", 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
+ }
+ }
+}
diff --git a/backend/src/LudosData.Api/Data/Migrations/20260804205124_AddPriceSourceId.cs b/backend/src/LudosData.Api/Data/Migrations/20260804205124_AddPriceSourceId.cs
new file mode 100644
index 0000000..c5ec15c
--- /dev/null
+++ b/backend/src/LudosData.Api/Data/Migrations/20260804205124_AddPriceSourceId.cs
@@ -0,0 +1,29 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace LudosData.Api.Data.Migrations
+{
+ ///
+ public partial class AddPriceSourceId : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "PriceSourceId",
+ table: "Games",
+ type: "TEXT",
+ maxLength: 100,
+ nullable: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "PriceSourceId",
+ table: "Games");
+ }
+ }
+}
diff --git a/backend/src/LudosData.Api/Data/Migrations/LudosDbContextModelSnapshot.cs b/backend/src/LudosData.Api/Data/Migrations/LudosDbContextModelSnapshot.cs
index d0c9e7d..65397be 100644
--- a/backend/src/LudosData.Api/Data/Migrations/LudosDbContextModelSnapshot.cs
+++ b/backend/src/LudosData.Api/Data/Migrations/LudosDbContextModelSnapshot.cs
@@ -149,6 +149,10 @@ namespace LudosData.Api.Data.Migrations
b.Property("Played")
.HasColumnType("INTEGER");
+ b.Property("PriceSourceId")
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
b.Property("Publisher")
.HasMaxLength(100)
.HasColumnType("TEXT");
diff --git a/backend/src/LudosData.Api/Domain/Game.cs b/backend/src/LudosData.Api/Domain/Game.cs
index 4a510e5..84942e9 100644
--- a/backend/src/LudosData.Api/Domain/Game.cs
+++ b/backend/src/LudosData.Api/Domain/Game.cs
@@ -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".
+ ///
+ /// 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.
+ ///
+ [MaxLength(100)]
+ public string? PriceSourceId { get; set; }
+
public decimal? ValueLoose { get; set; }
public decimal? ValueCib { get; set; }
public decimal? ValueNew { get; set; }
diff --git a/backend/src/LudosData.Api/Services/Pricing/EbayPriceProvider.cs b/backend/src/LudosData.Api/Services/Pricing/EbayPriceProvider.cs
index d4db639..dfc6681 100644
--- a/backend/src/LudosData.Api/Services/Pricing/EbayPriceProvider.cs
+++ b/backend/src/LudosData.Api/Services/Pricing/EbayPriceProvider.cs
@@ -35,7 +35,14 @@ public interface IPriceProvider
{
string Name { get; }
bool IsConfigured { get; }
- Task EstimateAsync(string title, string? system, CancellationToken ct = default);
+
+ ///
+ /// Prices a game. 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.
+ ///
+ Task EstimateAsync(
+ string title, string? system, string? sourceId = null, CancellationToken ct = default);
}
///
@@ -94,8 +101,12 @@ public class EbayPriceProvider(
};
public async Task 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(
diff --git a/backend/src/LudosData.Api/Services/Pricing/PriceChartingProvider.cs b/backend/src/LudosData.Api/Services/Pricing/PriceChartingProvider.cs
index 4dbb91d..936264f 100644
--- a/backend/src/LudosData.Api/Services/Pricing/PriceChartingProvider.cs
+++ b/backend/src/LudosData.Api/Services/Pricing/PriceChartingProvider.cs
@@ -70,7 +70,7 @@ public class PriceChartingProvider(
}
public async Task 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;
}
/// Prices arrive as integer pennies; 1250 means $12.50.
diff --git a/backend/src/LudosData.Api/Services/Pricing/PriceEstimate.cs b/backend/src/LudosData.Api/Services/Pricing/PriceEstimate.cs
index 4665548..0ad400a 100644
--- a/backend/src/LudosData.Api/Services/Pricing/PriceEstimate.cs
+++ b/backend/src/LudosData.Api/Services/Pricing/PriceEstimate.cs
@@ -21,6 +21,25 @@ public record PriceEstimate(
int NewSamples,
int Discarded)
{
+ ///
+ /// 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.
+ ///
+ public string? MatchedName { get; init; }
+ public string? MatchedConsole { get; init; }
+
+ ///
+ /// 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.
+ ///
+ 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);
diff --git a/backend/tests/LudosData.Api.Tests/PriceSourceTests.cs b/backend/tests/LudosData.Api.Tests/PriceSourceTests.cs
index db239d7..a9b628b 100644
--- a/backend/tests/LudosData.Api.Tests/PriceSourceTests.cs
+++ b/backend/tests/LudosData.Api.Tests/PriceSourceTests.cs
@@ -367,3 +367,60 @@ public class PriceEndpointTests(LudosApiFactory factory) : IClassFixture 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"));
+ }
+}