Add CSV price guides and PriceCharting alongside eBay

An eBay production keyset needs account verification, which leaves pricing
blocked on someone else's review queue. These are two routes that are not.

CSV price guide, POST /api/prices/import: no account, works immediately.
Column names are matched by alias, so a PriceCharting bulk export
(product-name / console-name / loose-price) and a hand-kept
title,system,loose,cib,new sheet both parse, along with currency symbols,
thousands separators and blank cells. Rows match on title + system, so the
same game on two consoles is priced separately, and rows for games not in
the library are reported rather than silently added.

PriceCharting adapter: paid, but access is immediate with no review, and it
quotes the same three tiers this app stores, so no inference is needed.
Their API docs are not reachable without an account, so the parser follows
the widely-used convention — integer pennies under hyphenated keys — and is
tolerant enough that a naming difference degrades to "no price" instead of
throwing. One method to adjust if it differs.

Providers are now a registry rather than a single service. /api/prices/status
lists each one with what it is configured for, what its numbers actually
mean, and how to enable it; refresh takes an optional provider name and
falls back to the first configured one. With none configured it answers 503
pointing at the CSV route.

Checked against the real library: a five-row guide in PriceCharting's own
column names priced four games and reported the fifth as not owned, with
each effective value following that copy's condition. Demo figures were
cleared afterwards.

135 backend tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-04 16:46:10 -04:00
co-authored by Claude Opus 5
parent ca70bcef34
commit 8e136f42f8
8 changed files with 889 additions and 66 deletions
+14 -1
View File
@@ -33,7 +33,20 @@ JWT_AUDIENCE=LudosData
CORS_ORIGIN=http://localhost:8080 CORS_ORIGIN=http://localhost:8080
# --- Market value (optional) ------------------------------------------------ # --- Market value (optional) ------------------------------------------------
# Prices come from eBay's Browse API, which needs a free developer account. # Three routes, none of which block the others. Nothing here is required: with
# all of it blank, prices can still be imported as a CSV or typed in by hand.
#
# 1. CSV PRICE GUIDE — no account, works immediately.
# POST a CSV to /api/prices/import. Columns are matched by name, so a
# PriceCharting bulk download, a spreadsheet you maintain, or any other list
# all work. Nothing to configure here.
#
# 2. PRICECHARTING — paid, but access is immediate with no review, which makes
# it the practical choice while an eBay keyset is in verification. Token
# comes from the Subscriptions page, "API/Download" button.
PRICECHARTING_TOKEN=
# 3. EBAY BROWSE — free, but the production keyset needs account verification.
# #
# 1. Register at https://developer.ebay.com and create a developer account # 1. Register at https://developer.ebay.com and create a developer account
# 2. Create an application keyset (Application Keys -> Production) # 2. Create an application keyset (Application Keys -> Production)
+38 -29
View File
@@ -216,43 +216,52 @@ keys: `rating`, `value`, `price`, `purchased`.
### Market value ### Market value
Prices come from **eBay's Browse API**, which needs a free developer account: Three routes, deliberately independent, because each is blocked differently.
register at developer.ebay.com, create a production application keyset, and put
the App ID and Cert ID in `.env` as `EBAY_CLIENT_ID` / `EBAY_CLIENT_SECRET`. | Route | Blocked by | Cost | Basis |
Without them the pricing endpoints answer 503 with an explanation and nothing | --- | --- | --- | --- |
else is affected. | **CSV price guide** | nothing | free | whatever you supply |
| **PriceCharting** | nothing — immediate on subscribing | paid | sale-derived, per condition |
| **eBay Browse** | production keyset needs account verification | free | asking prices, reads high |
| Manual entry | nothing | free | your own judgement |
``` ```
GET /api/prices/status is a provider configured? GET /api/prices/status which sources are usable
POST /api/prices/refresh {dryRun, limit} price some games POST /api/prices/refresh {provider, dryRun} price from a live source
POST /api/prices/import (multipart CSV) apply a price list
``` ```
**These are asking prices, not sold prices.** Browse returns active listings. **The CSV route needs no account and works today.** Column names are matched by
eBay's completed-sales data lives behind the Marketplace Insights API, which is alias, so `product-name` / `console-name` / `loose-price` from a PriceCharting
a limited release closed to new developers, and PriceCharting — the usual export and a hand-kept `title,system,loose,cib,new` sheet are both accepted, as
alternative — requires a paid subscription. Asking prices skew high: sellers are `$`, thousands separators and blank cells. Rows are matched on title +
list optimistically and unsold listings linger. Treat the numbers as an upper system, so the same game on two consoles is priced separately; rows for games
bound. The provider name (`ebay-asking`) is stored with every value it writes, you do not own are reported rather than added.
so the source is always visible next to the figure.
Deriving a price from listings takes more than an average: Free sources that do **not** work for this: eBay's completed-sales data sits
behind the Marketplace Insights API, a limited release closed to new
developers; NEXARDA and CheapShark price current retail and digital
storefronts, not collectibles. Scraping PriceCharting violates their terms.
- **Listings are classified into loose / CIB / new** from the title, because a Deriving a price from eBay listings takes more than an average:
feed of mixed conditions has no single price. Accessories are discarded
outright — a "box only" listing at $45 counted as a copy would halve the loose - **Listings are classified into loose / CIB / new** from their titles, since a
estimate for a $130 cartridge. So are reproductions and multi-game lots. mixed feed has no single price. Accessories are discarded — a "box only"
- **The qualifier is required when discarding.** An early version matched a bare listing at $45 counted as a copy would halve the loose estimate for a $130
"box", which threw away "complete in box" and "with box and manual" — most of cartridge — as are reproductions and multi-game lots.
the CIB tier — while keeping the cheap box-only listings the filter existed to - **The discard qualifier is required.** An early version matched a bare "box",
remove. Caught by a test asserting on the tier, not on the count. which threw away "complete in box" and "with box and manual" — most of the CIB
- **Median, not mean,** with an interquartile trim. One optimist asking 50x drags tier — while keeping exactly the listings the filter existed to remove. Caught
a mean past the point of usefulness; a median ignores them. by a test asserting on tiers rather than counts.
- **Sample counts travel with the estimate.** A tier from two listings deserves - **Median with an interquartile trim.** One optimist asking 50x moves a mean
less confidence than one from thirty. and not a median.
- **Sample counts travel with the estimate**, because a tier drawn from two
listings deserves less confidence than one drawn from thirty.
Three prices are stored per game, and `marketValue` is whichever tier matches Three prices are stored per game, and `marketValue` is whichever tier matches
that copy's condition. Changing a game's condition re-prices it from the stored that copy's condition — so changing a condition re-prices it with no further
tiers without another lookup. lookup. Every value carries the source that wrote it and the moment it was
captured.
### Database changes ### Database changes
@@ -1,5 +1,7 @@
using System.Text;
using LudosData.Api.Auth; using LudosData.Api.Auth;
using LudosData.Api.Data; using LudosData.Api.Data;
using LudosData.Api.Domain;
using LudosData.Api.Services.Pricing; using LudosData.Api.Services.Pricing;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -9,6 +11,9 @@ namespace LudosData.Api.Controllers;
public record PriceRefreshRequest public record PriceRefreshRequest
{ {
/// <summary>Which source to price from. Defaults to the first configured one.</summary>
public string? Provider { get; init; }
/// <summary>Limit the run to specific games. Empty means the whole library.</summary> /// <summary>Limit the run to specific games. Empty means the whole library.</summary>
public List<int>? GameIds { get; init; } public List<int>? GameIds { get; init; }
@@ -23,58 +28,75 @@ public record PriceRefreshRequest
} }
public record PriceRefreshItem( public record PriceRefreshItem(
int GameId, int GameId, string Title,
string Title, decimal? Loose, decimal? Cib, decimal? New,
decimal? Loose, int Samples, int Discarded, string? Error);
decimal? Cib,
decimal? New,
int Samples,
int Discarded,
string? Error);
public record PriceRefreshResult( public record PriceRefreshResult(
bool DryRun, bool DryRun, string Source, int Considered, int Priced, int Failed,
string Source,
int Considered,
int Priced,
int Failed,
IReadOnlyList<PriceRefreshItem> Items); IReadOnlyList<PriceRefreshItem> Items);
public record ProviderStatus(string Name, bool Configured, string Basis, string? Setup);
public record PriceImportResult(
bool DryRun, int Rows, int Matched, int Updated, int Unmatched,
IReadOnlyList<string> UnmatchedTitles, IReadOnlyList<string> Problems);
/// <summary> /// <summary>
/// Refreshes market values from a price provider. /// Market values, from whichever sources are configured.
/// ///
/// The figures are asking prices from active listings, not completed sales — /// Several can coexist because each is blocked in a different way: eBay is free
/// see <see cref="EbayPriceProvider"/> — so they read high. The provider name /// but its production keyset needs account verification, PriceCharting is
/// travels with every value it writes, so the dashboard can say where a number /// immediate but paid, and a CSV price guide needs neither. The provider name
/// came from and how old it is. /// travels with every value written, so a figure always says where it came from.
/// </summary> /// </summary>
[ApiController] [ApiController]
[Route("api/prices")] [Route("api/prices")]
[Authorize] [Authorize]
public class PricesController( public class PricesController(
LudosDbContext db, LudosDbContext db,
IPriceProvider provider, IEnumerable<IPriceProvider> providers,
ILogger<PricesController> logger) : ControllerBase ILogger<PricesController> logger) : ControllerBase
{ {
[HttpGet("status")] private static readonly Dictionary<string, (string Basis, string Setup)> ProviderNotes = new()
public IActionResult Status() => Ok(new
{ {
provider = provider.Name, ["ebay-asking"] = (
configured = provider.IsConfigured, "active listing asking prices, not completed sales — expect these to read high",
// Stated plainly so a caller cannot mistake these for sold prices. "Free, but the production keyset needs eBay account verification. "
basis = "active listing asking prices, not completed sales", + "Set EBAY_CLIENT_ID and EBAY_CLIENT_SECRET."),
}); ["pricecharting"] = (
"sale-derived prices quoted per condition",
"Paid subscription, but access is immediate with no review. "
+ "Set PRICECHARTING_TOKEN."),
};
[HttpGet("status")]
public ActionResult<IEnumerable<ProviderStatus>> Status() => Ok(providers.Select(p =>
{
var notes = ProviderNotes.TryGetValue(p.Name, out var n)
? n
: ("unspecified", "see documentation");
return new ProviderStatus(p.Name, p.IsConfigured, notes.Item1,
p.IsConfigured ? null : notes.Item2);
}));
[HttpPost("refresh")] [HttpPost("refresh")]
public async Task<ActionResult<PriceRefreshResult>> Refresh( public async Task<ActionResult<PriceRefreshResult>> Refresh(
PriceRefreshRequest request, CancellationToken ct) PriceRefreshRequest request, CancellationToken ct)
{ {
if (!provider.IsConfigured) var provider = ResolveProvider(request.Provider);
if (provider is null)
{ {
var available = string.Join(", ", providers.Select(p => p.Name));
return StatusCode(StatusCodes.Status503ServiceUnavailable, new ProblemDetails return StatusCode(StatusCodes.Status503ServiceUnavailable, new ProblemDetails
{ {
Title = "No price provider is configured.", Title = request.Provider is null
Detail = "Set Ebay:ClientId and Ebay:ClientSecret, then restart the API.", ? "No price provider is configured."
: $"Price provider '{request.Provider}' is not configured.",
Detail = $"Known providers: {available}. Configure one, or import a price "
+ "guide CSV at POST /api/prices/import, which needs no account.",
}); });
} }
@@ -112,12 +134,7 @@ public class PricesController(
if (!request.DryRun) if (!request.DryRun)
{ {
game.ValueLoose = estimate.Loose; ApplyEstimate(game, estimate.Loose, estimate.Cib, estimate.New, provider.Name);
game.ValueCib = estimate.Cib;
game.ValueNew = estimate.New;
game.RecalculateEffectiveValue();
game.MarketValueUpdatedAt = DateTimeOffset.UtcNow;
game.MarketValueSource = provider.Name;
} }
priced++; priced++;
@@ -145,4 +162,108 @@ public class PricesController(
return Ok(new PriceRefreshResult( return Ok(new PriceRefreshResult(
request.DryRun, provider.Name, games.Count, priced, failed, items)); request.DryRun, provider.Name, games.Count, priced, failed, items));
} }
/// <summary>
/// Applies an external price list.
///
/// This is the path that needs no account and no approval: export a guide
/// from wherever you have one, or keep a spreadsheet, and the prices land on
/// the matching games.
/// </summary>
[HttpPost("import")]
[RequestSizeLimit(32 * 1024 * 1024)]
public async Task<ActionResult<PriceImportResult>> Import(
IFormFile file,
[FromQuery] string source = "price-guide",
[FromQuery] bool dryRun = false,
CancellationToken ct = default)
{
if (file is null || file.Length == 0)
{
return BadRequest(new ProblemDetails { Title = "No file was uploaded." });
}
string text;
using (var reader = new StreamReader(file.OpenReadStream(), Encoding.UTF8, true))
{
text = await reader.ReadToEndAsync(ct);
}
var guide = PriceGuide.Parse(text);
if (guide.Rows.Count == 0)
{
return BadRequest(new ProblemDetails
{
Title = "No usable rows were found.",
Detail = string.Join(" ", guide.Problems),
});
}
var ownerId = User.GetUserId();
var games = await db.Games.Where(g => g.OwnerId == ownerId).ToListAsync(ct);
// Same key as the library import: a game is a title on a platform, so
// three Donkey Kong Countrys stay three separately priced entries.
var index = games
.GroupBy(g => Key(g.Title, g.System))
.ToDictionary(g => g.Key, g => g.First());
int matched = 0, updated = 0;
var unmatched = new List<string>();
foreach (var row in guide.Rows)
{
if (!index.TryGetValue(Key(row.Title, row.System), out var game))
{
unmatched.Add($"{row.Title}{(row.System is null ? "" : $" ({row.System})")}");
continue;
}
matched++;
if (row.Loose is null && row.Cib is null && row.New is null)
{
continue;
}
if (!dryRun)
{
ApplyEstimate(game, row.Loose, row.Cib, row.New, source);
}
updated++;
}
if (!dryRun && updated > 0)
{
await db.SaveChangesAsync(ct);
logger.LogInformation("User {OwnerId} priced {Count} games from a {Source} guide",
ownerId, updated, source);
}
return Ok(new PriceImportResult(
dryRun, guide.Rows.Count, matched, updated, unmatched.Count,
// Capped: a full guide can miss thousands of rows that are simply
// games this library does not contain.
unmatched.Take(50).ToList(), guide.Problems));
}
private IPriceProvider? ResolveProvider(string? name) =>
name is null
? providers.FirstOrDefault(p => p.IsConfigured)
: providers.FirstOrDefault(p =>
string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase) && p.IsConfigured);
private static void ApplyEstimate(
Game game, decimal? loose, decimal? cib, decimal? boxed, string source)
{
game.ValueLoose = loose;
game.ValueCib = cib;
game.ValueNew = boxed;
game.RecalculateEffectiveValue();
game.MarketValueUpdatedAt = DateTimeOffset.UtcNow;
game.MarketValueSource = source;
}
private static string Key(string title, string? system) =>
$"{title.Trim().ToLowerInvariant()} {(system ?? string.Empty).Trim().ToLowerInvariant()}";
} }
+13 -1
View File
@@ -117,8 +117,20 @@ builder.Services.AddSingleton<IImageStorage, ImageStorage>();
// Pricing. The provider is registered whether or not credentials are present; // Pricing. The provider is registered whether or not credentials are present;
// it reports IsConfigured so the endpoint can answer 503 with a useful message // it reports IsConfigured so the endpoint can answer 503 with a useful message
// rather than the app failing to start without an optional integration. // rather than the app failing to start without an optional integration.
builder.Services.Configure<EbayOptions>(builder.Configuration.GetSection(EbayOptions.SectionName)); // Providers are registered whether or not credentials are present; each reports
// IsConfigured so the endpoint can explain what is missing rather than the app
// refusing to start without an optional integration. Order is the fallback
// order when no provider is named: PriceCharting first, since it quotes
// sale-derived prices per condition, then eBay's asking prices.
builder.Services.Configure<PriceChartingOptions>(
builder.Configuration.GetSection(PriceChartingOptions.SectionName));
builder.Services.Configure<EbayOptions>(
builder.Configuration.GetSection(EbayOptions.SectionName));
builder.Services.AddHttpClient("pricecharting", client => client.Timeout = TimeSpan.FromSeconds(30));
builder.Services.AddHttpClient("ebay", client => client.Timeout = TimeSpan.FromSeconds(30)); builder.Services.AddHttpClient("ebay", client => client.Timeout = TimeSpan.FromSeconds(30));
builder.Services.AddSingleton<IPriceProvider, PriceChartingProvider>();
builder.Services.AddSingleton<IPriceProvider, EbayPriceProvider>(); builder.Services.AddSingleton<IPriceProvider, EbayPriceProvider>();
builder.Services builder.Services
@@ -0,0 +1,162 @@
using System.Text.Json;
using Microsoft.Extensions.Options;
namespace LudosData.Api.Services.Pricing;
public class PriceChartingOptions
{
public const string SectionName = "PriceCharting";
/// <summary>
/// Subscription token, from the Subscriptions page of a PriceCharting
/// account (the "API/Download" button).
/// </summary>
public string Token { get; set; } = string.Empty;
public bool IsConfigured => !string.IsNullOrWhiteSpace(Token);
}
/// <summary>
/// Prices from PriceCharting, which quotes loose, complete and new separately —
/// the same three tiers this app stores, so no inference is needed and the
/// numbers are sale-derived rather than asking prices.
///
/// It needs a paid subscription, but access is immediate on subscribing, with
/// no application or review. That makes it the practical option when an eBay
/// developer account is stuck in verification.
///
/// One caveat worth knowing when you first run it: PriceCharting's published
/// API documentation is not reachable without an account, so the response shape
/// below follows their widely-used convention — prices as integer pennies under
/// hyphenated keys. <see cref="Parse"/> is deliberately tolerant and is the only
/// place to adjust if their field names differ from this.
/// </summary>
public class PriceChartingProvider(
IHttpClientFactory httpClientFactory,
IOptions<PriceChartingOptions> options,
ILogger<PriceChartingProvider> logger) : IPriceProvider
{
private readonly PriceChartingOptions _options = options.Value;
public string Name => "pricecharting";
public bool IsConfigured => _options.IsConfigured;
/// <summary>
/// PriceCharting keys its catalogue by console name, so the query carries
/// one. Their names are spelled out rather than abbreviated.
/// </summary>
internal static string ConsoleName(string? system) => (system ?? string.Empty).ToUpperInvariant() switch
{
"NES" => "nes",
"SNES" => "super nintendo",
"N64" => "nintendo 64",
"GC" => "gamecube",
"WII" => "wii",
"GB" => "gameboy",
"GBA" => "gameboy advance",
"DS" => "nintendo ds",
"PS1" => "playstation",
"PS2" => "playstation 2",
"PSP" => "psp",
"360" => "xbox 360",
_ => system?.ToLowerInvariant() ?? string.Empty,
};
internal static string BuildQuery(string title, string? system)
{
var console = ConsoleName(system);
return string.IsNullOrWhiteSpace(console) ? title : $"{console} {title}";
}
public async Task<PriceEstimate> EstimateAsync(
string title, string? system, CancellationToken ct = default)
{
if (!IsConfigured)
{
throw new InvalidOperationException(
"PriceCharting is not configured. Set PriceCharting:Token.");
}
var client = httpClientFactory.CreateClient("pricecharting");
var url = "https://www.pricecharting.com/api/product"
+ $"?t={Uri.EscapeDataString(_options.Token)}"
+ $"&q={Uri.EscapeDataString(BuildQuery(title, system))}";
using var response = await client.GetAsync(url, ct);
if (!response.IsSuccessStatusCode)
{
logger.LogWarning("PriceCharting lookup for {Title} returned {Status}",
title, response.StatusCode);
return PriceEstimate.Empty;
}
await using var stream = await response.Content.ReadAsStreamAsync(ct);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: ct);
return Parse(document);
}
/// <summary>
/// Reads the three tiers from a product response.
///
/// Internal so it can be tested on a fixture without a subscription, and
/// tolerant of both hyphenated and camelCase keys so a naming difference
/// degrades to "no price" rather than a crash.
/// </summary>
internal static PriceEstimate Parse(JsonDocument document)
{
var root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
return PriceEstimate.Empty;
}
// A miss is reported in-band with a status field rather than by HTTP.
if (root.TryGetProperty("status", out var status)
&& string.Equals(status.GetString(), "error", StringComparison.OrdinalIgnoreCase))
{
return PriceEstimate.Empty;
}
var loose = ReadPennies(root, "loose-price", "loosePrice");
var cib = ReadPennies(root, "cib-price", "cibPrice");
var boxed = ReadPennies(root, "new-price", "newPrice");
// A single quoted price per tier, so the sample count is one where a
// price exists — the estimate carries its own confidence either way.
return new PriceEstimate(
loose, cib, boxed,
loose is null ? 0 : 1,
cib is null ? 0 : 1,
boxed is null ? 0 : 1,
0);
}
/// <summary>Prices arrive as integer pennies; 1250 means $12.50.</summary>
private static decimal? ReadPennies(JsonElement root, params string[] names)
{
foreach (var name in names)
{
if (!root.TryGetProperty(name, out var element))
{
continue;
}
long? pennies = element.ValueKind switch
{
JsonValueKind.Number when element.TryGetInt64(out var value) => value,
JsonValueKind.String when long.TryParse(element.GetString(), out var value) => value,
_ => null,
};
// Zero means "no price on record", not "free".
if (pennies is > 0)
{
return pennies.Value / 100m;
}
}
return null;
}
}
@@ -0,0 +1,136 @@
using System.Globalization;
using LudosData.Api.Services;
namespace LudosData.Api.Services.Pricing;
/// <summary>One row of an external price list.</summary>
public record PriceGuideRow(
string Title,
string? System,
decimal? Loose,
decimal? Cib,
decimal? New);
public record PriceGuideParseResult(
IReadOnlyList<PriceGuideRow> Rows,
IReadOnlyList<string> Problems);
/// <summary>
/// Reads a price list out of a CSV.
///
/// The point is to be source-agnostic. A PriceCharting subscriber's bulk
/// download, a spreadsheet kept by hand, and a list exported from anywhere else
/// all describe the same thing — a title, a platform and some prices — so this
/// accepts the column names each of them tends to use rather than demanding one
/// fixed schema.
/// </summary>
public static class PriceGuide
{
// Header aliases, lowercased and stripped of spaces, underscores and hyphens.
private static readonly string[] TitleNames =
["title", "productname", "product", "name", "game", "gamename"];
private static readonly string[] SystemNames =
["system", "console", "consolename", "platform"];
private static readonly string[] LooseNames =
["loose", "looseprice", "loosevalue", "cartonly", "value"];
private static readonly string[] CibNames =
["cib", "cibprice", "complete", "completeprice", "cibvalue", "completeinbox"];
private static readonly string[] NewNames =
["new", "newprice", "sealed", "sealedprice", "newvalue", "graded"];
private static string Normalise(string header) =>
new(header.Trim().ToLowerInvariant()
.Where(c => char.IsLetterOrDigit(c))
.ToArray());
public static PriceGuideParseResult Parse(string text)
{
var problems = new List<string>();
var rows = Csv.Parse(text);
if (rows.Count == 0)
{
return new PriceGuideParseResult([], ["The file is empty."]);
}
var header = rows[0].Select(Normalise).ToList();
int Find(string[] names)
{
foreach (var name in names)
{
var at = header.IndexOf(name);
if (at >= 0) return at;
}
return -1;
}
var titleAt = Find(TitleNames);
if (titleAt < 0)
{
return new PriceGuideParseResult([],
["No title column found. Expected one of: title, product-name, name, game."]);
}
var systemAt = Find(SystemNames);
var looseAt = Find(LooseNames);
var cibAt = Find(CibNames);
var newAt = Find(NewNames);
if (looseAt < 0 && cibAt < 0 && newAt < 0)
{
return new PriceGuideParseResult([],
["No price column found. Expected at least one of: loose, cib, new."]);
}
var parsed = new List<PriceGuideRow>();
for (var i = 1; i < rows.Count; i++)
{
var row = rows[i];
string? Cell(int at) =>
at >= 0 && at < row.Count && row[at].Trim().Length > 0 ? row[at].Trim() : null;
var title = Cell(titleAt);
if (title is null)
{
problems.Add($"Row {i + 1}: no title");
continue;
}
parsed.Add(new PriceGuideRow(
title, Cell(systemAt),
ParseMoney(Cell(looseAt)), ParseMoney(Cell(cibAt)), ParseMoney(Cell(newAt))));
}
return new PriceGuideParseResult(parsed, problems);
}
/// <summary>
/// Tolerant of what a spreadsheet emits: currency symbols, thousands
/// separators, and integer pennies where a guide quotes them that way.
/// </summary>
public static decimal? ParseMoney(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var cleaned = value.Trim().TrimStart('$', '£', '€').Replace(",", string.Empty).Trim();
if (!decimal.TryParse(cleaned, NumberStyles.Number, CultureInfo.InvariantCulture, out var parsed))
{
return null;
}
// Zero means "no price on record", not "free".
return parsed > 0 ? parsed : null;
}
}
@@ -0,0 +1,369 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using LudosData.Api.Services.Pricing;
namespace LudosData.Api.Tests;
public class PriceGuideParsingTests
{
[Fact]
public void A_plain_guide_is_read()
{
const string csv = """
title,console,loose,cib,new
Chrono Trigger,Super Nintendo,128.00,650.00,12000.00
Super Metroid,Super Nintendo,55.00,210.00,4000.00
""";
var result = PriceGuide.Parse(csv);
Assert.Equal(2, result.Rows.Count);
Assert.Empty(result.Problems);
var first = result.Rows[0];
Assert.Equal("Chrono Trigger", first.Title);
Assert.Equal("Super Nintendo", first.System);
Assert.Equal(128.00m, first.Loose);
Assert.Equal(650.00m, first.Cib);
Assert.Equal(12000.00m, first.New);
}
[Theory]
// Different exports name the same columns differently; all of these mean
// the same thing, and demanding one schema would make the feature useless
// for whichever guide the user actually has.
[InlineData("product-name,console-name,loose-price,cib-price,new-price")]
[InlineData("Product Name,Console Name,Loose Price,CIB Price,New Price")]
[InlineData("game,platform,loose,complete,sealed")]
[InlineData("NAME,SYSTEM,LOOSE,CIB,NEW")]
public void Column_aliases_and_casing_are_accepted(string header)
{
var result = PriceGuide.Parse($"{header}\nChrono Trigger,SNES,128.00,650.00,12000.00");
var row = Assert.Single(result.Rows);
Assert.Equal("Chrono Trigger", row.Title);
Assert.Equal("SNES", row.System);
Assert.Equal(128.00m, row.Loose);
}
[Theory]
[InlineData("$128.00", 128.00)]
[InlineData("1,234.56", 1234.56)]
[InlineData(" $1,234.56 ", 1234.56)]
[InlineData("128", 128)]
public void Spreadsheet_formatting_is_tolerated(string input, double expected)
{
Assert.Equal((decimal)expected, PriceGuide.ParseMoney(input));
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("0")]
[InlineData("0.00")]
[InlineData("n/a")]
public void Blank_and_zero_prices_read_as_no_price(string input)
{
// Zero means "not on record", and treating it as free would drag a
// collection total toward nothing.
Assert.Null(PriceGuide.ParseMoney(input));
}
[Fact]
public void A_guide_with_no_title_column_is_rejected_with_a_reason()
{
var result = PriceGuide.Parse("foo,bar\n1,2");
Assert.Empty(result.Rows);
Assert.Contains("title", Assert.Single(result.Problems), StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void A_guide_with_no_price_column_is_rejected_with_a_reason()
{
var result = PriceGuide.Parse("title,console\nChrono Trigger,SNES");
Assert.Empty(result.Rows);
Assert.Contains("price", Assert.Single(result.Problems), StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void A_partial_guide_is_usable()
{
// Plenty of lists quote one condition only.
var result = PriceGuide.Parse("title,system,loose\nChrono Trigger,SNES,128.00");
var row = Assert.Single(result.Rows);
Assert.Equal(128.00m, row.Loose);
Assert.Null(row.Cib);
Assert.Null(row.New);
}
[Fact]
public void Quoted_titles_containing_commas_survive()
{
var result = PriceGuide.Parse("title,system,loose\n\"Spyro 2: Ripto's Rage!, The Best\",PS1,40.00");
Assert.Equal("Spyro 2: Ripto's Rage!, The Best", Assert.Single(result.Rows).Title);
}
}
public class PriceChartingParsingTests
{
[Fact]
public void Pennies_are_converted_to_currency()
{
using var document = JsonDocument.Parse("""
{
"status": "success",
"product-name": "Chrono Trigger",
"console-name": "Super Nintendo",
"loose-price": 12800,
"cib-price": 65000,
"new-price": 1200000
}
""");
var estimate = PriceChartingProvider.Parse(document);
Assert.Equal(128.00m, estimate.Loose);
Assert.Equal(650.00m, estimate.Cib);
Assert.Equal(12000.00m, estimate.New);
}
[Fact]
public void A_zero_price_is_treated_as_absent()
{
using var document = JsonDocument.Parse("""
{ "status": "success", "loose-price": 12800, "cib-price": 0, "new-price": 0 }
""");
var estimate = PriceChartingProvider.Parse(document);
Assert.Equal(128.00m, estimate.Loose);
// Zero means no price on record, not a free game.
Assert.Null(estimate.Cib);
Assert.Null(estimate.New);
}
[Fact]
public void An_error_response_yields_no_prices()
{
using var document = JsonDocument.Parse("""{ "status": "error", "error-message": "not found" }""");
Assert.False(PriceChartingProvider.Parse(document).HasAnyPrice);
}
[Fact]
public void Unexpected_field_names_degrade_to_no_price_rather_than_throwing()
{
// Their docs are not reachable without an account, so a naming
// difference has to be survivable.
using var document = JsonDocument.Parse("""{ "status": "success", "somethingElse": 1 }""");
Assert.False(PriceChartingProvider.Parse(document).HasAnyPrice);
}
[Theory]
[InlineData("SNES", "super nintendo Chrono Trigger")]
[InlineData("N64", "nintendo 64 GoldenEye")]
[InlineData("360", "xbox 360 Halo 3")]
public void The_console_name_leads_the_query(string system, string expected)
{
var title = expected.Split(' ').Last() == "Trigger" ? "Chrono Trigger"
: expected.Contains("GoldenEye") ? "GoldenEye" : "Halo 3";
Assert.Equal(expected, PriceChartingProvider.BuildQuery(title, system));
}
}
public class PriceEndpointTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory>
{
private static MultipartFormDataContent CsvContent(string body)
{
var content = new MultipartFormDataContent();
var part = new ByteArrayContent(Encoding.UTF8.GetBytes(body));
part.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("text/csv");
content.Add(part, "file", "guide.csv");
return content;
}
private static async Task SeedAsync(HttpClient client)
{
await client.PostJsonAsync("/api/games", new
{
title = "Chrono Trigger", system = "SNES", own = true, condition = "Cib",
});
await client.PostJsonAsync("/api/games", new
{
title = "Super Metroid", system = "SNES", own = true, condition = "Loose",
});
}
[Fact]
public async Task Status_lists_every_provider_and_how_to_enable_it()
{
var client = await factory.CreateUserClientAsync("price-status");
var statuses = await client.GetJsonAsync<List<ProviderStatusPayload>>("/api/prices/status");
Assert.Contains(statuses!, s => s.Name == "ebay-asking");
Assert.Contains(statuses!, s => s.Name == "pricecharting");
// None are configured in tests, so each explains what is missing.
Assert.All(statuses!, s =>
{
Assert.False(s.Configured);
Assert.False(string.IsNullOrWhiteSpace(s.Setup));
});
}
[Fact]
public async Task Refresh_without_a_configured_provider_points_at_the_csv_route()
{
var client = await factory.CreateUserClientAsync("price-none");
var response = await client.PostJsonAsync("/api/prices/refresh", new { limit = 1 });
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
var problem = await response.Content.ReadAsStringAsync();
Assert.Contains("/api/prices/import", problem);
}
[Fact]
public async Task A_price_guide_sets_tiers_and_the_effective_value_follows_condition()
{
var client = await factory.CreateUserClientAsync("price-import");
await SeedAsync(client);
const string csv = """
title,system,loose,cib,new
Chrono Trigger,SNES,128.00,650.00,12000.00
Super Metroid,SNES,55.00,210.00,4000.00
""";
var result = await (await client.PostAsync("/api/prices/import?source=my-guide", CsvContent(csv)))
.Content.ReadJsonAsync<PriceImportPayload>();
Assert.Equal(2, result!.Matched);
Assert.Equal(2, result.Updated);
Assert.Equal(0, result.Unmatched);
var page = await client.GetJsonAsync<PagePayload>("/api/games?sort=title");
var chrono = page!.Items.Single(g => g.Title == "Chrono Trigger");
var metroid = page.Items.Single(g => g.Title == "Super Metroid");
Assert.Equal(650.00m, chrono.MarketValue); // CIB copy takes the CIB tier
Assert.Equal(55.00m, metroid.MarketValue); // loose copy takes the loose tier
Assert.Equal("my-guide", chrono.MarketValueSource);
Assert.NotNull(chrono.MarketValueUpdatedAt);
}
[Fact]
public async Task Rows_for_games_not_in_the_library_are_reported_not_created()
{
var client = await factory.CreateUserClientAsync("price-unmatched");
await SeedAsync(client);
const string csv = """
title,system,loose
Chrono Trigger,SNES,128.00
EarthBound,SNES,300.00
""";
var result = await (await client.PostAsync("/api/prices/import", CsvContent(csv)))
.Content.ReadJsonAsync<PriceImportPayload>();
Assert.Equal(1, result!.Matched);
Assert.Equal(1, result.Unmatched);
Assert.Contains("EarthBound", result.UnmatchedTitles[0]);
// A price guide prices what you own; it does not add to the collection.
var page = await client.GetJsonAsync<PagePayload>("/api/games");
Assert.Equal(2, page!.Total);
}
[Fact]
public async Task The_same_title_on_two_systems_is_priced_separately()
{
var client = await factory.CreateUserClientAsync("price-platform");
await client.PostJsonAsync("/api/games", new { title = "Donkey Kong Country", system = "SNES", own = true });
await client.PostJsonAsync("/api/games", new { title = "Donkey Kong Country", system = "GBA", own = true });
const string csv = """
title,system,loose
Donkey Kong Country,SNES,25.00
Donkey Kong Country,GBA,18.00
""";
await client.PostAsync("/api/prices/import", CsvContent(csv));
var page = await client.GetJsonAsync<PagePayload>("/api/games");
Assert.Equal(25.00m, page!.Items.Single(g => g.System == "SNES").MarketValue);
Assert.Equal(18.00m, page.Items.Single(g => g.System == "GBA").MarketValue);
}
[Fact]
public async Task Dry_run_reports_without_writing()
{
var client = await factory.CreateUserClientAsync("price-dry");
await SeedAsync(client);
var result = await (await client.PostAsync(
"/api/prices/import?dryRun=true",
CsvContent("title,system,loose\nChrono Trigger,SNES,128.00")))
.Content.ReadJsonAsync<PriceImportPayload>();
Assert.True(result!.DryRun);
Assert.Equal(1, result.Updated);
var page = await client.GetJsonAsync<PagePayload>("/api/games?search=Chrono");
Assert.Null(page!.Items[0].MarketValue);
}
[Fact]
public async Task A_guide_never_reaches_another_users_library()
{
var alice = await factory.CreateUserClientAsync("price-alice");
var bob = await factory.CreateUserClientAsync("price-bob");
await SeedAsync(alice);
await SeedAsync(bob);
await bob.PostAsync("/api/prices/import", CsvContent("title,system,loose\nChrono Trigger,SNES,999.00"));
var alicePage = await alice.GetJsonAsync<PagePayload>("/api/games?search=Chrono");
Assert.Null(alicePage!.Items[0].MarketValue);
}
[Fact]
public async Task An_unreadable_guide_is_rejected_with_a_reason()
{
var client = await factory.CreateUserClientAsync("price-bad");
var response = await client.PostAsync("/api/prices/import", CsvContent("foo,bar\n1,2"));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Contains("title", await response.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Pricing_endpoints_require_a_token()
{
var anonymous = factory.CreateClient();
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync("/api/prices/status")).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized,
(await anonymous.PostAsync("/api/prices/import", CsvContent("title,loose\nX,1"))).StatusCode);
}
private record ProviderStatusPayload(string Name, bool Configured, string Basis, string? Setup);
private record PriceImportPayload(
bool DryRun, int Rows, int Matched, int Updated, int Unmatched,
List<string> UnmatchedTitles, List<string> Problems);
private record GamePayload(
int Id, string Title, string? System, decimal? MarketValue,
DateTimeOffset? MarketValueUpdatedAt, string? MarketValueSource,
decimal? ValueLoose, decimal? ValueCib, decimal? ValueNew);
private record PagePayload(List<GamePayload> Items, int Total);
}
+1
View File
@@ -32,6 +32,7 @@ services:
# Optional market-value lookups. Blank means the pricing endpoints report # Optional market-value lookups. Blank means the pricing endpoints report
# 503 and everything else carries on. # 503 and everything else carries on.
PriceCharting__Token: ${PRICECHARTING_TOKEN:-}
Ebay__ClientId: ${EBAY_CLIENT_ID:-} Ebay__ClientId: ${EBAY_CLIENT_ID:-}
Ebay__ClientSecret: ${EBAY_CLIENT_SECRET:-} Ebay__ClientSecret: ${EBAY_CLIENT_SECRET:-}
Ebay__UseSandbox: ${EBAY_USE_SANDBOX:-false} Ebay__UseSandbox: ${EBAY_USE_SANDBOX:-false}