Add collector fields, including market value

Rating, notes, condition, region, purchase price and date, plus a market
value carrying the timestamp and source that make it interpretable.

Condition is load-bearing rather than cosmetic: price feeds quote per
condition, so it selects which quoted price applies to a copy. Market value
records when it was captured and where it came from — a collection total is
only as good as its staleness — and an edit to an unrelated field leaves
that timestamp alone, so a stale price cannot start looking freshly checked.

Two storage decisions worth naming:

  * Money is stored as integer minor units. SQLite has no decimal type and
    EF Core maps decimal to TEXT, which compares lexically: "9.00" sorts
    above "10.00" and SUM is unavailable. A value converter keeps decimals
    in C# while ordering and totalling work. A test pins the ordering.
  * Enums serialise as names. The default is ordinals, which meant the API
    rejected the browser's {"condition":"Cib"} with a 400 while the C# tests
    passed, because they round-tripped ints and never spoke the client's
    dialect. The tests now share the API's serializer options.

Also fixes a data-loss bug in the Python tools. Both built their PUT body
from a hardcoded list of field names, so any column added to the model was
omitted and therefore nulled. Adding collector fields meant the next art or
enrichment run would have erased every rating, note, condition, price and
valuation in the library. Payloads are now built by excluding the handful of
server-owned fields, so new columns carry through by default.

The migration was rehearsed against a copy of the live database before being
applied: 105 rows, descriptions and developers intact.

67 backend tests, 8 frontend.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-04 13:29:48 -04:00
co-authored by Claude Opus 5
parent b69a5c9d14
commit d5a0e42fed
29 changed files with 1585 additions and 105 deletions
@@ -1,5 +1,7 @@
using System.ComponentModel.DataAnnotations;
using LudosData.Api.Domain;
namespace LudosData.Api.Contracts;
/// <summary>A page of results plus the totals the paginator needs.</summary>
@@ -28,6 +30,15 @@ public record GameResponse(
bool Dumped,
bool Played,
bool Finished,
int? Rating,
string? Notes,
GameCondition Condition,
GameRegion Region,
decimal? PurchasePrice,
DateOnly? PurchaseDate,
decimal? MarketValue,
DateTimeOffset? MarketValueUpdatedAt,
string? MarketValueSource,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt);
@@ -52,6 +63,24 @@ public record GameRequest
public bool Dumped { get; init; }
public bool Played { get; init; }
public bool Finished { get; init; }
[Range(1, 10)] public int? Rating { get; init; }
[MaxLength(10_000)] public string? Notes { get; init; }
public GameCondition Condition { get; init; } = GameCondition.Unspecified;
public GameRegion Region { get; init; } = GameRegion.Unspecified;
[Range(0, 1_000_000)] public decimal? PurchasePrice { get; init; }
public DateOnly? PurchaseDate { get; init; }
/// <summary>
/// Current estimated resale value. Accepted here so a figure can be entered
/// by hand; a price feed will later write the same field, stamping
/// MarketValueUpdatedAt and MarketValueSource as it goes.
/// </summary>
[Range(0, 1_000_000)] public decimal? MarketValue { get; init; }
[MaxLength(100)] public string? MarketValueSource { get; init; }
}
/// <summary>Query string for the library list, bound from [FromQuery].</summary>
@@ -68,12 +97,24 @@ public record GameQuery
public bool? Played { get; init; }
public bool? Finished { get; init; }
public GameCondition? Condition { get; init; }
public GameRegion? Region { get; init; }
/// <summary>Lowest personal score to include. Unrated games are excluded when set.</summary>
[Range(1, 10)] public int? MinRating { get; init; }
/// <summary>Restrict to games that do, or do not, have a market value recorded.</summary>
public bool? HasValue { get; init; }
[Range(1, int.MaxValue)] public int Page { get; init; } = 1;
/// <summary>Capped at 100 to keep a hostile or buggy client from asking for everything.</summary>
[Range(1, 100)] public int PageSize { get; init; } = 20;
/// <summary>One of: title, system, genre, year, developer, publisher, created, updated.</summary>
/// <summary>
/// One of: title, system, genre, year, developer, publisher, rating,
/// value, price, purchased, created, updated.
/// </summary>
public string Sort { get; init; } = "title";
/// <summary>"asc" or "desc".</summary>
@@ -1,3 +1,5 @@
using LudosData.Api.Domain;
namespace LudosData.Api.Contracts;
/// <summary>
@@ -28,6 +30,18 @@ public record ExportGame
public bool Dumped { get; init; }
public bool Played { get; init; }
public bool Finished { get; init; }
// Collector fields travel with the export; a backup that quietly dropped
// ratings, notes and valuations would not be a backup.
public int? Rating { get; init; }
public string? Notes { get; init; }
public GameCondition Condition { get; init; }
public GameRegion Region { get; init; }
public decimal? PurchasePrice { get; init; }
public DateOnly? PurchaseDate { get; init; }
public decimal? MarketValue { get; init; }
public DateTimeOffset? MarketValueUpdatedAt { get; init; }
public string? MarketValueSource { get; init; }
}
/// <summary>Envelope written by the JSON exporter.</summary>
@@ -48,6 +48,18 @@ public class GamesController(
if (query.Played is { } played) q = q.Where(g => g.Played == played);
if (query.Finished is { } finished) q = q.Where(g => g.Finished == finished);
if (query.Condition is { } condition) q = q.Where(g => g.Condition == condition);
if (query.Region is { } region) q = q.Where(g => g.Region == region);
// An unrated game is not a zero-rated one, so it drops out of a
// minimum-rating filter rather than sorting to the bottom.
if (query.MinRating is { } minRating) q = q.Where(g => g.Rating >= minRating);
if (query.HasValue is { } hasValue)
{
q = hasValue ? q.Where(g => g.MarketValue != null) : q.Where(g => g.MarketValue == null);
}
var total = await q.CountAsync(ct);
q = ApplySort(q, query.Sort, query.Dir);
@@ -150,6 +162,10 @@ public class GamesController(
"year" => descending ? q.OrderByDescending(g => g.Year) : q.OrderBy(g => g.Year),
"developer" => descending ? q.OrderByDescending(g => g.Developer) : q.OrderBy(g => g.Developer),
"publisher" => descending ? q.OrderByDescending(g => g.Publisher) : q.OrderBy(g => g.Publisher),
"rating" => descending ? q.OrderByDescending(g => g.Rating) : q.OrderBy(g => g.Rating),
"value" => descending ? q.OrderByDescending(g => g.MarketValue) : q.OrderBy(g => g.MarketValue),
"price" => descending ? q.OrderByDescending(g => g.PurchasePrice) : q.OrderBy(g => g.PurchasePrice),
"purchased" => descending ? q.OrderByDescending(g => g.PurchaseDate) : q.OrderBy(g => g.PurchaseDate),
"created" => descending ? q.OrderByDescending(g => g.CreatedAt) : q.OrderBy(g => g.CreatedAt),
"updated" => descending ? q.OrderByDescending(g => g.UpdatedAt) : q.OrderBy(g => g.UpdatedAt),
_ => descending ? q.OrderByDescending(g => g.Title) : q.OrderBy(g => g.Title),
@@ -170,10 +186,36 @@ public class GamesController(
game.Dumped = request.Dumped;
game.Played = request.Played;
game.Finished = request.Finished;
game.Rating = request.Rating;
game.Notes = request.Notes;
game.Condition = request.Condition;
game.Region = request.Region;
game.PurchasePrice = request.PurchasePrice;
game.PurchaseDate = request.PurchaseDate;
// Only stamp the valuation when the figure actually changes, so an
// unrelated edit does not make a stale price look freshly checked.
if (request.MarketValue != game.MarketValue)
{
game.MarketValue = request.MarketValue;
game.MarketValueUpdatedAt = request.MarketValue is null ? null : DateTimeOffset.UtcNow;
game.MarketValueSource = request.MarketValue is null
? null
: request.MarketValueSource?.Trim() ?? "manual";
}
else if (request.MarketValueSource is { } source && game.MarketValue is not null)
{
game.MarketValueSource = source.Trim();
}
}
private GameResponse ToResponse(Game g, string ownerId) => new(
g.Id, g.Title, g.System, g.Genre, g.Year, g.Developer, g.Publisher,
g.Art, images.BuildUrl(ownerId, g.Art), g.Description,
g.Own, g.Dumped, g.Played, g.Finished, g.CreatedAt, g.UpdatedAt);
g.Own, g.Dumped, g.Played, g.Finished,
g.Rating, g.Notes, g.Condition, g.Region,
g.PurchasePrice, g.PurchaseDate,
g.MarketValue, g.MarketValueUpdatedAt, g.MarketValueSource,
g.CreatedAt, g.UpdatedAt);
}
@@ -1,3 +1,4 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
using LudosData.Api.Auth;
@@ -30,10 +31,18 @@ public class LibraryController(
[
"title", "system", "genre", "year", "developer", "publisher",
"description", "art", "own", "dumped", "played", "finished",
"rating", "notes", "condition", "region",
"purchasePrice", "purchaseDate", "marketValue", "marketValueUpdatedAt",
"marketValueSource",
];
private static readonly JsonSerializerOptions JsonOptions =
new(JsonSerializerDefaults.Web) { WriteIndented = true };
// Must match the converter registered on the controllers, so an export
// written with enum names is readable by the importer.
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
};
// ---- export ----------------------------------------------------------
@@ -56,6 +65,16 @@ public class LibraryController(
g.Title, g.System, g.Genre, g.Year, g.Developer, g.Publisher,
g.Description, g.Art,
g.Own.ToString(), g.Dumped.ToString(), g.Played.ToString(), g.Finished.ToString(),
g.Rating?.ToString(), g.Notes,
g.Condition == GameCondition.Unspecified ? null : g.Condition.ToString(),
g.Region == GameRegion.Unspecified ? null : g.Region.ToString(),
// Invariant culture throughout: a comma decimal separator would
// collide with the delimiter, and dates must not depend on locale.
g.PurchasePrice?.ToString(CultureInfo.InvariantCulture),
g.PurchaseDate?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
g.MarketValue?.ToString(CultureInfo.InvariantCulture),
g.MarketValueUpdatedAt?.ToString("O", CultureInfo.InvariantCulture),
g.MarketValueSource,
}));
// A BOM keeps Excel from mangling non-ASCII titles such as Pokémon.
@@ -265,12 +284,48 @@ public class LibraryController(
Dumped = Flag(row, "dumped"),
Played = Flag(row, "played"),
Finished = Flag(row, "finished"),
Rating = ParseInt(Field(row, "rating")),
Notes = Field(row, "notes"),
Condition = ParseEnum<GameCondition>(Field(row, "condition")),
Region = ParseEnum<GameRegion>(Field(row, "region")),
PurchasePrice = ParseMoney(Field(row, "purchaseprice")),
PurchaseDate = ParseDate(Field(row, "purchasedate")),
MarketValue = ParseMoney(Field(row, "marketvalue")),
MarketValueUpdatedAt = ParseTimestamp(Field(row, "marketvalueupdatedat")),
MarketValueSource = Field(row, "marketvaluesource"),
});
}
return games;
}
// Parsers are forgiving: a spreadsheet round-trip is a normal way for these
// files to arrive, and one unreadable cell should not cost the whole row.
private static int? ParseInt(string? value) =>
int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
? parsed : null;
private static decimal? ParseMoney(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
// Tolerate a currency symbol and thousands separators from a spreadsheet.
var cleaned = value.Trim().TrimStart('$', '£', '€').Replace(",", string.Empty);
return decimal.TryParse(cleaned, NumberStyles.Number, CultureInfo.InvariantCulture, out var parsed)
? parsed : null;
}
private static DateOnly? ParseDate(string? value) =>
DateOnly.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)
? parsed : null;
private static DateTimeOffset? ParseTimestamp(string? value) =>
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsed)
? parsed : null;
private static T ParseEnum<T>(string? value) where T : struct, Enum =>
Enum.TryParse<T>(value, ignoreCase: true, out var parsed) ? parsed : default;
private static string Key(string title, string? system) =>
$"{title.Trim().ToLowerInvariant()}{(system ?? string.Empty).Trim().ToLowerInvariant()}";
@@ -288,6 +343,19 @@ public class LibraryController(
target.Dumped = source.Dumped;
target.Played = source.Played;
target.Finished = source.Finished;
target.Rating = source.Rating;
target.Notes = Blank(source.Notes);
target.Condition = source.Condition;
target.Region = source.Region;
target.PurchasePrice = source.PurchasePrice;
target.PurchaseDate = source.PurchaseDate;
// The valuation's own timestamp is restored as recorded rather than
// reset to now: an import is a restore, not a fresh price check.
target.MarketValue = source.MarketValue;
target.MarketValueUpdatedAt = source.MarketValue is null ? null : source.MarketValueUpdatedAt;
target.MarketValueSource = source.MarketValue is null ? null : Blank(source.MarketValueSource);
}
private static string? Blank(string? value) =>
@@ -307,5 +375,14 @@ public class LibraryController(
Dumped = g.Dumped,
Played = g.Played,
Finished = g.Finished,
Rating = g.Rating,
Notes = g.Notes,
Condition = g.Condition,
Region = g.Region,
PurchasePrice = g.PurchasePrice,
PurchaseDate = g.PurchaseDate,
MarketValue = g.MarketValue,
MarketValueUpdatedAt = g.MarketValueUpdatedAt,
MarketValueSource = g.MarketValueSource,
};
}
@@ -1,6 +1,7 @@
using LudosData.Api.Domain;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace LudosData.Api.Data;
@@ -13,6 +14,15 @@ public class LudosDbContext(DbContextOptions<LudosDbContext> options)
{
base.OnModelCreating(builder);
// SQLite has no decimal type. EF Core's default is to store decimal as
// TEXT, which compares lexically — "9.00" sorts above "10.00", and SUM
// is not available at all. Money is therefore stored as integer minor
// units and converted on the way in and out, so ordering by value and
// totalling a collection both behave.
var moneyToCents = new ValueConverter<decimal?, long?>(
value => value == null ? null : (long)Math.Round(value.Value * 100m, MidpointRounding.AwayFromZero),
cents => cents == null ? null : cents.Value / 100m);
builder.Entity<Game>(game =>
{
game.HasOne(g => g.Owner)
@@ -20,11 +30,19 @@ public class LudosDbContext(DbContextOptions<LudosDbContext> options)
.HasForeignKey(g => g.OwnerId)
.OnDelete(DeleteBehavior.Cascade);
game.Property(g => g.PurchasePrice).HasConversion(moneyToCents);
game.Property(g => g.MarketValue).HasConversion(moneyToCents);
// Stored as an enum's underlying int; readable names live in the API.
game.Property(g => g.Condition).HasConversion<int>();
game.Property(g => g.Region).HasConversion<int>();
// Every list query filters by owner first, then narrows or sorts on
// these columns, so they lead the composite indexes.
game.HasIndex(g => new { g.OwnerId, g.Title });
game.HasIndex(g => new { g.OwnerId, g.System });
game.HasIndex(g => new { g.OwnerId, g.Genre });
game.HasIndex(g => new { g.OwnerId, g.Rating });
});
}
@@ -0,0 +1,397 @@
// <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("20260804171435_AddCollectorFields")]
partial class AddCollectorFields
{
/// <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>("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<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,121 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LudosData.Api.Data.Migrations
{
/// <inheritdoc />
public partial class AddCollectorFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Condition",
table: "Games",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<long>(
name: "MarketValue",
table: "Games",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "MarketValueSource",
table: "Games",
type: "TEXT",
maxLength: 100,
nullable: true);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "MarketValueUpdatedAt",
table: "Games",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Notes",
table: "Games",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<DateOnly>(
name: "PurchaseDate",
table: "Games",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<long>(
name: "PurchasePrice",
table: "Games",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "Rating",
table: "Games",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "Region",
table: "Games",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateIndex(
name: "IX_Games_OwnerId_Rating",
table: "Games",
columns: new[] { "OwnerId", "Rating" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Games_OwnerId_Rating",
table: "Games");
migrationBuilder.DropColumn(
name: "Condition",
table: "Games");
migrationBuilder.DropColumn(
name: "MarketValue",
table: "Games");
migrationBuilder.DropColumn(
name: "MarketValueSource",
table: "Games");
migrationBuilder.DropColumn(
name: "MarketValueUpdatedAt",
table: "Games");
migrationBuilder.DropColumn(
name: "Notes",
table: "Games");
migrationBuilder.DropColumn(
name: "PurchaseDate",
table: "Games");
migrationBuilder.DropColumn(
name: "PurchasePrice",
table: "Games");
migrationBuilder.DropColumn(
name: "Rating",
table: "Games");
migrationBuilder.DropColumn(
name: "Region",
table: "Games");
}
}
}
@@ -103,6 +103,9 @@ namespace LudosData.Api.Data.Migrations
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<int>("Condition")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
@@ -123,6 +126,19 @@ namespace LudosData.Api.Data.Migrations
.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");
@@ -137,6 +153,18 @@ namespace LudosData.Api.Data.Migrations
.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");
@@ -157,6 +185,8 @@ namespace LudosData.Api.Data.Migrations
b.HasIndex("OwnerId", "Genre");
b.HasIndex("OwnerId", "Rating");
b.HasIndex("OwnerId", "System");
b.HasIndex("OwnerId", "Title");
+34
View File
@@ -0,0 +1,34 @@
namespace LudosData.Api.Domain;
/// <summary>
/// Physical completeness. This is not cosmetic: market price feeds quote per
/// condition, and the gap between loose and sealed is routinely a multiple, so
/// this selects which quoted price applies to a copy.
/// </summary>
public enum GameCondition
{
Unspecified = 0,
/// <summary>Cartridge or disc only.</summary>
Loose = 1,
/// <summary>Complete in box — case, manual and inserts present.</summary>
Cib = 2,
/// <summary>Factory sealed, never opened.</summary>
Sealed = 3,
/// <summary>No physical copy; a download or licence.</summary>
Digital = 4,
}
/// <summary>
/// Release region. Affects both value and playability on a given console.
/// </summary>
public enum GameRegion
{
Unspecified = 0,
Ntsc = 1, // North America
Pal = 2, // Europe / Australia
NtscJ = 3, // Japan
}
+35
View File
@@ -47,6 +47,41 @@ public class Game
public bool Played { get; set; }
public bool Finished { get; set; }
// ---- collector fields ------------------------------------------------
/// <summary>Personal score out of 10. Null means unrated, which is not zero.</summary>
[Range(1, 10)]
public int? Rating { get; set; }
/// <summary>
/// Free-form personal notes. Kept separate from Description, which is
/// derived from an external source and may be overwritten by the enricher.
/// </summary>
public string? Notes { get; set; }
public GameCondition Condition { get; set; } = GameCondition.Unspecified;
public GameRegion Region { get; set; } = GameRegion.Unspecified;
/// <summary>What was paid for this copy. A fixed historical fact.</summary>
public decimal? PurchasePrice { get; set; }
public DateOnly? PurchaseDate { get; set; }
// ---- market value ----------------------------------------------------
//
// Distinct from PurchasePrice: an estimate of what a copy sells for now,
// expected to be refreshed from a price feed. Stored with the moment it was
// captured and where it came from, because a figure with neither is not
// something you can reason about — a total is only as good as its staleness.
public decimal? MarketValue { get; set; }
public DateTimeOffset? MarketValueUpdatedAt { get; set; }
/// <summary>Provenance, e.g. a price feed's name, or "manual".</summary>
[MaxLength(100)]
public string? MarketValueSource { get; set; }
/// <summary>
/// Owning user. Every query is filtered on this server-side, from the JWT subject —
/// it is never accepted from the client.
+11 -1
View File
@@ -1,4 +1,5 @@
using System.Text;
using System.Text.Json.Serialization;
using LudosData.Api.Auth;
using LudosData.Api.Data;
using LudosData.Api.Domain;
@@ -112,7 +113,16 @@ builder.Services.AddAuthorization();
builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddSingleton<IImageStorage, ImageStorage>();
builder.Services.AddControllers();
builder.Services
.AddControllers()
.AddJsonOptions(options =>
{
// Enums travel as names, not ordinals. "Cib" is self-describing in a
// payload, an export file and a log line; 2 is not, and renumbering the
// enum would silently reinterpret every stored export.
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
builder.Services.AddProblemDetails();
builder.Services.AddOpenApi();
+16 -16
View File
@@ -17,10 +17,10 @@ public class AuthTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory>
{
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/auth/register", Registration("reg-ok"));
var response = await client.PostJsonAsync("/api/auth/register", Registration("reg-ok"));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var auth = await response.Content.ReadFromJsonAsync<LudosApiFactory.AuthPayload>();
var auth = await response.Content.ReadJsonAsync<LudosApiFactory.AuthPayload>();
Assert.False(string.IsNullOrWhiteSpace(auth!.Token));
Assert.Equal("reg-ok", auth.User.UserName);
Assert.True(auth.ExpiresAt > DateTimeOffset.UtcNow);
@@ -35,7 +35,7 @@ public class AuthTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory>
{
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync(
var response = await client.PostJsonAsync(
"/api/auth/register", Registration($"weak-{password.Length}-{password[0]}", password));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
@@ -45,9 +45,9 @@ public class AuthTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory>
public async Task Register_rejects_a_duplicate_username()
{
var client = factory.CreateClient();
await client.PostAsJsonAsync("/api/auth/register", Registration("dupe-user"));
await client.PostJsonAsync("/api/auth/register", Registration("dupe-user"));
var second = await client.PostAsJsonAsync("/api/auth/register", Registration("dupe-user"));
var second = await client.PostJsonAsync("/api/auth/register", Registration("dupe-user"));
Assert.Equal(HttpStatusCode.BadRequest, second.StatusCode);
}
@@ -56,9 +56,9 @@ public class AuthTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory>
public async Task Login_succeeds_with_the_right_password()
{
var client = factory.CreateClient();
await client.PostAsJsonAsync("/api/auth/register", Registration("login-ok"));
await client.PostJsonAsync("/api/auth/register", Registration("login-ok"));
var response = await client.PostAsJsonAsync(
var response = await client.PostJsonAsync(
"/api/auth/login", new { userName = "login-ok", password = "TestPassword123" });
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
@@ -68,9 +68,9 @@ public class AuthTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory>
public async Task Login_rejects_a_wrong_password()
{
var client = factory.CreateClient();
await client.PostAsJsonAsync("/api/auth/register", Registration("login-bad"));
await client.PostJsonAsync("/api/auth/register", Registration("login-bad"));
var response = await client.PostAsJsonAsync(
var response = await client.PostJsonAsync(
"/api/auth/login", new { userName = "login-bad", password = "WrongPassword123" });
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
@@ -80,11 +80,11 @@ public class AuthTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory>
public async Task Login_does_not_reveal_whether_a_username_exists()
{
var client = factory.CreateClient();
await client.PostAsJsonAsync("/api/auth/register", Registration("enum-real"));
await client.PostJsonAsync("/api/auth/register", Registration("enum-real"));
var wrongPassword = await client.PostAsJsonAsync(
var wrongPassword = await client.PostJsonAsync(
"/api/auth/login", new { userName = "enum-real", password = "WrongPassword123" });
var noSuchUser = await client.PostAsJsonAsync(
var noSuchUser = await client.PostJsonAsync(
"/api/auth/login", new { userName = "enum-absent", password = "WrongPassword123" });
// Identical status and body, so the endpoint cannot be used to harvest
@@ -99,11 +99,11 @@ public class AuthTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory>
public async Task Availability_reports_taken_and_free_names_without_leaking_the_row()
{
var client = factory.CreateClient();
await client.PostAsJsonAsync("/api/auth/register", Registration("taken-name"));
await client.PostJsonAsync("/api/auth/register", Registration("taken-name"));
var taken = await client.GetFromJsonAsync<AvailabilityPayload>(
var taken = await client.GetJsonAsync<AvailabilityPayload>(
"/api/auth/available?userName=taken-name");
var free = await client.GetFromJsonAsync<AvailabilityPayload>(
var free = await client.GetJsonAsync<AvailabilityPayload>(
"/api/auth/available?userName=definitely-free-name");
Assert.False(taken!.Available);
@@ -131,7 +131,7 @@ public class AuthTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory>
{
var client = await factory.CreateUserClientAsync("me-user");
var user = await client.GetFromJsonAsync<LudosApiFactory.UserPayload>("/api/auth/me");
var user = await client.GetJsonAsync<LudosApiFactory.UserPayload>("/api/auth/me");
Assert.Equal("me-user", user!.UserName);
}
@@ -0,0 +1,325 @@
using System.Net;
using System.Net.Http.Json;
using LudosData.Api.Contracts;
using LudosData.Api.Domain;
namespace LudosData.Api.Tests;
public class CollectorFieldTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory>
{
private static object Game(
string title,
string system = "SNES",
int? rating = null,
string? notes = null,
GameCondition condition = GameCondition.Unspecified,
GameRegion region = GameRegion.Unspecified,
decimal? purchasePrice = null,
string? purchaseDate = null,
decimal? marketValue = null,
string? marketValueSource = null) => new
{
title, system, own = true,
rating, notes, condition, region,
purchasePrice, purchaseDate, marketValue, marketValueSource,
};
private static async Task<GamePayload> CreateAsync(HttpClient client, object body)
{
var response = await client.PostJsonAsync("/api/games", body);
response.EnsureSuccessStatusCode();
return (await response.Content.ReadJsonAsync<GamePayload>())!;
}
[Fact]
public async Task Collector_fields_round_trip()
{
var client = await factory.CreateUserClientAsync("cf-roundtrip");
var created = await CreateAsync(client, Game(
"Panzer Dragoon Saga",
rating: 9,
notes: "Bought at a swap meet. Disc 2 has a scratch.",
condition: GameCondition.Cib,
region: GameRegion.Ntsc,
purchasePrice: 249.99m,
purchaseDate: "2019-06-14",
marketValue: 1150.00m,
marketValueSource: "pricecharting"));
Assert.Equal(9, created.Rating);
Assert.Equal("Bought at a swap meet. Disc 2 has a scratch.", created.Notes);
Assert.Equal(GameCondition.Cib, created.Condition);
Assert.Equal(GameRegion.Ntsc, created.Region);
Assert.Equal(249.99m, created.PurchasePrice);
Assert.Equal(new DateOnly(2019, 6, 14), created.PurchaseDate);
Assert.Equal(1150.00m, created.MarketValue);
Assert.Equal("pricecharting", created.MarketValueSource);
}
[Fact]
public async Task Money_keeps_its_cents_through_storage()
{
var client = await factory.CreateUserClientAsync("cf-cents");
// Money is stored as integer minor units, so the awkward values are the
// ones worth checking.
var created = await CreateAsync(client, Game("Cent Test",
purchasePrice: 0.01m, marketValue: 19.99m));
Assert.Equal(0.01m, created.PurchasePrice);
Assert.Equal(19.99m, created.MarketValue);
var reloaded = await client.GetJsonAsync<GamePayload>($"/api/games/{created.Id}");
Assert.Equal(0.01m, reloaded!.PurchasePrice);
Assert.Equal(19.99m, reloaded.MarketValue);
}
[Fact]
public async Task Sorting_by_value_is_numeric_not_lexical()
{
var client = await factory.CreateUserClientAsync("cf-sort");
await CreateAsync(client, Game("Nine", marketValue: 9m));
await CreateAsync(client, Game("Ten", marketValue: 10m));
await CreateAsync(client, Game("Hundred", marketValue: 100m));
var page = await client.GetJsonAsync<PagePayload>("/api/games?sort=value&dir=desc");
// Stored as text, "9" would sort above "100" and this would read
// Nine, Ten, Hundred.
Assert.Equal(["Hundred", "Ten", "Nine"], page!.Items.Select(g => g.Title));
}
[Fact]
public async Task Rating_must_be_between_1_and_10()
{
var client = await factory.CreateUserClientAsync("cf-rating");
Assert.Equal(HttpStatusCode.BadRequest,
(await client.PostJsonAsync("/api/games", Game("Too low", rating: 0))).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest,
(await client.PostJsonAsync("/api/games", Game("Too high", rating: 11))).StatusCode);
// Null is unrated, which is legitimate and not the same as zero.
var unrated = await CreateAsync(client, Game("Unrated"));
Assert.Null(unrated.Rating);
}
[Fact]
public async Task A_minimum_rating_filter_excludes_unrated_games()
{
var client = await factory.CreateUserClientAsync("cf-minrating");
await CreateAsync(client, Game("Great", rating: 9));
await CreateAsync(client, Game("Fine", rating: 6));
await CreateAsync(client, Game("Unrated"));
var page = await client.GetJsonAsync<PagePayload>("/api/games?minRating=7");
Assert.Equal("Great", Assert.Single(page!.Items).Title);
}
[Fact]
public async Task Condition_and_region_filter()
{
var client = await factory.CreateUserClientAsync("cf-filters");
await CreateAsync(client, Game("Sealed Copy", condition: GameCondition.Sealed, region: GameRegion.Ntsc));
await CreateAsync(client, Game("Loose Copy", condition: GameCondition.Loose, region: GameRegion.Pal));
var sealedOnly = await client.GetJsonAsync<PagePayload>("/api/games?condition=Sealed");
var palOnly = await client.GetJsonAsync<PagePayload>("/api/games?region=Pal");
Assert.Equal("Sealed Copy", Assert.Single(sealedOnly!.Items).Title);
Assert.Equal("Loose Copy", Assert.Single(palOnly!.Items).Title);
}
[Fact]
public async Task HasValue_separates_valued_from_unvalued_games()
{
var client = await factory.CreateUserClientAsync("cf-hasvalue");
await CreateAsync(client, Game("Valued", marketValue: 40m));
await CreateAsync(client, Game("Unvalued"));
var valued = await client.GetJsonAsync<PagePayload>("/api/games?hasValue=true");
var unvalued = await client.GetJsonAsync<PagePayload>("/api/games?hasValue=false");
Assert.Equal("Valued", Assert.Single(valued!.Items).Title);
Assert.Equal("Unvalued", Assert.Single(unvalued!.Items).Title);
}
[Fact]
public async Task Setting_a_value_stamps_when_and_where_it_came_from()
{
var client = await factory.CreateUserClientAsync("cf-stamp");
var before = DateTimeOffset.UtcNow.AddSeconds(-1);
var created = await CreateAsync(client, Game("Stamped", marketValue: 55m));
Assert.NotNull(created.MarketValueUpdatedAt);
Assert.True(created.MarketValueUpdatedAt >= before);
// No source given, so it is recorded as hand-entered.
Assert.Equal("manual", created.MarketValueSource);
}
[Fact]
public async Task An_unrelated_edit_does_not_make_a_stale_valuation_look_fresh()
{
var client = await factory.CreateUserClientAsync("cf-nostamp");
var created = await CreateAsync(client, Game("Keeps Its Date", marketValue: 30m));
var originalStamp = created.MarketValueUpdatedAt;
await Task.Delay(20);
// Change the notes, leave the value alone.
var updated = await (await client.PutJsonAsync($"/api/games/{created.Id}",
Game("Keeps Its Date", notes: "Edited something else", marketValue: 30m)))
.Content.ReadJsonAsync<GamePayload>();
Assert.Equal("Edited something else", updated!.Notes);
Assert.Equal(originalStamp, updated.MarketValueUpdatedAt);
}
[Fact]
public async Task Changing_the_value_moves_the_timestamp()
{
var client = await factory.CreateUserClientAsync("cf-restamp");
var created = await CreateAsync(client, Game("Repriced", marketValue: 30m));
await Task.Delay(20);
var updated = await (await client.PutJsonAsync($"/api/games/{created.Id}",
Game("Repriced", marketValue: 45m))).Content.ReadJsonAsync<GamePayload>();
Assert.Equal(45m, updated!.MarketValue);
Assert.True(updated.MarketValueUpdatedAt > created.MarketValueUpdatedAt);
}
[Fact]
public async Task Clearing_the_value_clears_its_metadata_too()
{
var client = await factory.CreateUserClientAsync("cf-clear");
var created = await CreateAsync(client, Game("Devalued", marketValue: 30m, marketValueSource: "feed"));
var updated = await (await client.PutJsonAsync($"/api/games/{created.Id}",
Game("Devalued"))).Content.ReadJsonAsync<GamePayload>();
Assert.Null(updated!.MarketValue);
Assert.Null(updated.MarketValueUpdatedAt);
Assert.Null(updated.MarketValueSource);
}
[Fact]
public async Task Negative_money_is_rejected()
{
var client = await factory.CreateUserClientAsync("cf-negative");
Assert.Equal(HttpStatusCode.BadRequest,
(await client.PostJsonAsync("/api/games", Game("Negative", purchasePrice: -5m))).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest,
(await client.PostJsonAsync("/api/games", Game("Negative", marketValue: -5m))).StatusCode);
}
// ---- export / import -------------------------------------------------
[Fact]
public async Task Collector_fields_survive_a_json_round_trip()
{
var source = await factory.CreateUserClientAsync("cf-json-src");
await CreateAsync(source, Game("Full House",
rating: 8, notes: "note", condition: GameCondition.Cib, region: GameRegion.NtscJ,
purchasePrice: 12.34m, purchaseDate: "2020-01-02",
marketValue: 56.78m, marketValueSource: "feed"));
var exported = await source.GetStringAsync("/api/library/export?format=json");
var target = await factory.CreateUserClientAsync("cf-json-dst");
await target.PostAsync("/api/library/import", FileContent(exported, "l.json"));
var game = Assert.Single((await target.GetJsonAsync<LibraryExport>(
"/api/library/export?format=json"))!.Games);
Assert.Equal(8, game.Rating);
Assert.Equal(GameCondition.Cib, game.Condition);
Assert.Equal(GameRegion.NtscJ, game.Region);
Assert.Equal(12.34m, game.PurchasePrice);
Assert.Equal(new DateOnly(2020, 1, 2), game.PurchaseDate);
Assert.Equal(56.78m, game.MarketValue);
Assert.Equal("feed", game.MarketValueSource);
}
[Fact]
public async Task Collector_fields_survive_a_csv_round_trip()
{
var source = await factory.CreateUserClientAsync("cf-csv-src");
await CreateAsync(source, Game("CSV House",
rating: 7, condition: GameCondition.Sealed, region: GameRegion.Pal,
purchasePrice: 99.95m, purchaseDate: "2021-11-30", marketValue: 250m));
var csv = await source.GetStringAsync("/api/library/export?format=csv");
var target = await factory.CreateUserClientAsync("cf-csv-dst");
await target.PostAsync("/api/library/import", FileContent(csv, "l.csv"));
var game = Assert.Single((await target.GetJsonAsync<LibraryExport>(
"/api/library/export?format=json"))!.Games);
Assert.Equal(7, game.Rating);
Assert.Equal(GameCondition.Sealed, game.Condition);
Assert.Equal(GameRegion.Pal, game.Region);
Assert.Equal(99.95m, game.PurchasePrice);
Assert.Equal(250m, game.MarketValue);
}
[Fact]
public async Task Importing_restores_a_valuation_date_rather_than_resetting_it()
{
var client = await factory.CreateUserClientAsync("cf-import-date");
// A valuation captured well in the past should still read as old after a
// restore — an import is not a fresh price check.
var json = """
[{ "title": "Old Valuation", "system": "PS1", "own": true,
"marketValue": 42.00, "marketValueUpdatedAt": "2020-03-01T00:00:00+00:00",
"marketValueSource": "archive" }]
""";
await client.PostAsync("/api/library/import", FileContent(json, "l.json"));
var game = Assert.Single((await client.GetJsonAsync<LibraryExport>(
"/api/library/export?format=json"))!.Games);
Assert.Equal(2020, game.MarketValueUpdatedAt!.Value.Year);
Assert.Equal("archive", game.MarketValueSource);
}
[Fact]
public async Task Spreadsheet_style_money_is_accepted_on_import()
{
var client = await factory.CreateUserClientAsync("cf-messy-money");
// What a spreadsheet actually emits after someone formats a column.
const string csv = """
title,system,own,purchasePrice,marketValue
Formatted,PS2,true,"$1,234.56","$2,000.00"
""";
await client.PostAsync("/api/library/import", FileContent(csv, "l.csv"));
var game = Assert.Single((await client.GetJsonAsync<LibraryExport>(
"/api/library/export?format=json"))!.Games);
Assert.Equal(1234.56m, game.PurchasePrice);
Assert.Equal(2000m, game.MarketValue);
}
private static MultipartFormDataContent FileContent(string body, string name)
{
var content = new MultipartFormDataContent();
content.Add(new ByteArrayContent(System.Text.Encoding.UTF8.GetBytes(body)), "file", name);
return content;
}
private record GamePayload(
int Id, string Title, int? Rating, string? Notes,
GameCondition Condition, GameRegion Region,
decimal? PurchasePrice, DateOnly? PurchaseDate,
decimal? MarketValue, DateTimeOffset? MarketValueUpdatedAt, string? MarketValueSource);
private record PagePayload(List<GamePayload> Items, int Total);
}
+30 -30
View File
@@ -16,11 +16,11 @@ public class GamesTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory
private static async Task SeedLibraryAsync(HttpClient client)
{
await client.PostAsJsonAsync("/api/games", Game("Chrono Trigger", "SNES", "rpg", "1995", developer: "Square"));
await client.PostAsJsonAsync("/api/games", Game("Super Metroid", "SNES", "platformer", "1994"));
await client.PostAsJsonAsync("/api/games", Game("GoldenEye 007", "N64", "fps", "1997", publisher: "Nintendo"));
await client.PostAsJsonAsync("/api/games", Game("Banjo-Kazooie", "N64", "adventure", "1998", played: true, finished: true));
await client.PostAsJsonAsync("/api/games", Game("Ico", "PS2", "adventure", "2001", played: true));
await client.PostJsonAsync("/api/games", Game("Chrono Trigger", "SNES", "rpg", "1995", developer: "Square"));
await client.PostJsonAsync("/api/games", Game("Super Metroid", "SNES", "platformer", "1994"));
await client.PostJsonAsync("/api/games", Game("GoldenEye 007", "N64", "fps", "1997", publisher: "Nintendo"));
await client.PostJsonAsync("/api/games", Game("Banjo-Kazooie", "N64", "adventure", "1998", played: true, finished: true));
await client.PostJsonAsync("/api/games", Game("Ico", "PS2", "adventure", "2001", played: true));
}
[Fact]
@@ -29,7 +29,7 @@ public class GamesTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory
var client = await factory.CreateUserClientAsync("page-user");
await SeedLibraryAsync(client);
var page = await client.GetFromJsonAsync<PagePayload>("/api/games?page=1&pageSize=2");
var page = await client.GetJsonAsync<PagePayload>("/api/games?page=1&pageSize=2");
Assert.Equal(2, page!.Items.Count);
Assert.Equal(5, page.Total);
@@ -42,9 +42,9 @@ public class GamesTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory
var client = await factory.CreateUserClientAsync("search-user");
await SeedLibraryAsync(client);
var byTitle = await client.GetFromJsonAsync<PagePayload>("/api/games?search=metroid");
var byDeveloper = await client.GetFromJsonAsync<PagePayload>("/api/games?search=Square");
var byPublisher = await client.GetFromJsonAsync<PagePayload>("/api/games?search=Nintendo");
var byTitle = await client.GetJsonAsync<PagePayload>("/api/games?search=metroid");
var byDeveloper = await client.GetJsonAsync<PagePayload>("/api/games?search=Square");
var byPublisher = await client.GetJsonAsync<PagePayload>("/api/games?search=Nintendo");
Assert.Equal("Super Metroid", Assert.Single(byTitle!.Items).Title);
Assert.Equal("Chrono Trigger", Assert.Single(byDeveloper!.Items).Title);
@@ -57,10 +57,10 @@ public class GamesTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory
var client = await factory.CreateUserClientAsync("filter-user");
await SeedLibraryAsync(client);
var n64 = await client.GetFromJsonAsync<PagePayload>("/api/games?system=N64");
var adventure = await client.GetFromJsonAsync<PagePayload>("/api/games?genre=adventure");
var finished = await client.GetFromJsonAsync<PagePayload>("/api/games?finished=true");
var unplayed = await client.GetFromJsonAsync<PagePayload>("/api/games?played=false");
var n64 = await client.GetJsonAsync<PagePayload>("/api/games?system=N64");
var adventure = await client.GetJsonAsync<PagePayload>("/api/games?genre=adventure");
var finished = await client.GetJsonAsync<PagePayload>("/api/games?finished=true");
var unplayed = await client.GetJsonAsync<PagePayload>("/api/games?played=false");
Assert.Equal(2, n64!.Total);
Assert.Equal(2, adventure!.Total);
@@ -74,8 +74,8 @@ public class GamesTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory
var client = await factory.CreateUserClientAsync("sort-user");
await SeedLibraryAsync(client);
var ascending = await client.GetFromJsonAsync<PagePayload>("/api/games?sort=title&dir=asc");
var descending = await client.GetFromJsonAsync<PagePayload>("/api/games?sort=title&dir=desc");
var ascending = await client.GetJsonAsync<PagePayload>("/api/games?sort=title&dir=asc");
var descending = await client.GetJsonAsync<PagePayload>("/api/games?sort=title&dir=desc");
Assert.Equal("Banjo-Kazooie", ascending!.Items.First().Title);
Assert.Equal("Super Metroid", descending!.Items.First().Title);
@@ -92,11 +92,11 @@ public class GamesTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory
var response = await client.GetAsync("/api/games?sort=id);DROP%20TABLE%20Games;--");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var page = await response.Content.ReadFromJsonAsync<PagePayload>();
var page = await response.Content.ReadJsonAsync<PagePayload>();
Assert.Equal("Banjo-Kazooie", page!.Items.First().Title);
// And the table is still there.
var after = await client.GetFromJsonAsync<PagePayload>("/api/games");
var after = await client.GetJsonAsync<PagePayload>("/api/games");
Assert.Equal(5, after!.Total);
}
@@ -118,8 +118,8 @@ public class GamesTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory
{
var client = await factory.CreateUserClientAsync("title-required");
var empty = await client.PostAsJsonAsync("/api/games", new { title = "", system = "SNES" });
var whitespace = await client.PostAsJsonAsync("/api/games", new { title = " ", system = "SNES" });
var empty = await client.PostJsonAsync("/api/games", new { title = "", system = "SNES" });
var whitespace = await client.PostJsonAsync("/api/games", new { title = " ", system = "SNES" });
Assert.Equal(HttpStatusCode.BadRequest, empty.StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, whitespace.StatusCode);
@@ -129,13 +129,13 @@ public class GamesTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory
public async Task Update_changes_fields_and_moves_the_updated_timestamp()
{
var client = await factory.CreateUserClientAsync("update-user");
var created = await (await client.PostAsJsonAsync("/api/games", Game("Before")))
.Content.ReadFromJsonAsync<GamePayload>();
var created = await (await client.PostJsonAsync("/api/games", Game("Before")))
.Content.ReadJsonAsync<GamePayload>();
await Task.Delay(15); // the stamp has sub-second resolution, but not zero
var updated = await (await client.PutAsJsonAsync(
var updated = await (await client.PutJsonAsync(
$"/api/games/{created!.Id}", Game("After", finished: true)))
.Content.ReadFromJsonAsync<GamePayload>();
.Content.ReadJsonAsync<GamePayload>();
Assert.Equal("After", updated!.Title);
Assert.True(updated.Finished);
@@ -147,8 +147,8 @@ public class GamesTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory
public async Task Delete_removes_the_game()
{
var client = await factory.CreateUserClientAsync("delete-user");
var created = await (await client.PostAsJsonAsync("/api/games", Game("Doomed")))
.Content.ReadFromJsonAsync<GamePayload>();
var created = await (await client.PostJsonAsync("/api/games", Game("Doomed")))
.Content.ReadJsonAsync<GamePayload>();
var response = await client.DeleteAsync($"/api/games/{created!.Id}");
@@ -161,13 +161,13 @@ public class GamesTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory
{
var client = await factory.CreateUserClientAsync("null-user");
var created = await (await client.PostAsJsonAsync("/api/games", new
var created = await (await client.PostJsonAsync("/api/games", new
{
title = " Trimmed ",
system = (string?)null,
genre = (string?)null,
own = true,
})).Content.ReadFromJsonAsync<GamePayload>();
})).Content.ReadJsonAsync<GamePayload>();
Assert.Equal("Trimmed", created!.Title);
Assert.Null(created.System);
@@ -188,7 +188,7 @@ public class GamesTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory
var response = await client.PostAsync("/api/images", content);
response.EnsureSuccessStatusCode();
var upload = await response.Content.ReadFromJsonAsync<UploadPayload>();
var upload = await response.Content.ReadJsonAsync<UploadPayload>();
Assert.EndsWith(".webp", upload!.FileName);
// The stored name is generated server-side, never taken from the upload.
@@ -240,9 +240,9 @@ public class GamesTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory
image.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("image/png");
content.Add(image, "file", "a.png");
var upload = await (await alice.PostAsync("/api/images", content))
.Content.ReadFromJsonAsync<UploadPayload>();
.Content.ReadJsonAsync<UploadPayload>();
var bobUser = await bob.GetFromJsonAsync<LudosApiFactory.UserPayload>("/api/auth/me");
var bobUser = await bob.GetJsonAsync<LudosApiFactory.UserPayload>("/api/auth/me");
var file = upload!.Url.Split('/').Last();
var probe = await bob.GetAsync($"/uploads/{bobUser!.Id}/{file}");
@@ -0,0 +1,33 @@
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace LudosData.Api.Tests;
/// <summary>
/// JSON helpers configured exactly like the API's own serializer.
///
/// Without this the tests would speak a different dialect from the browser:
/// System.Text.Json writes enums as ordinals by default, so a test could pass
/// while the real client's <c>"condition": "Cib"</c> was rejected with a 400 —
/// which is precisely what happened before the API adopted string enums.
/// </summary>
internal static class HttpJson
{
public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
{
Converters = { new JsonStringEnumConverter() },
};
public static Task<HttpResponseMessage> PostJsonAsync<T>(this HttpClient client, string url, T value)
=> client.PostAsJsonAsync(url, value, Options);
public static Task<HttpResponseMessage> PutJsonAsync<T>(this HttpClient client, string url, T value)
=> client.PutAsJsonAsync(url, value, Options);
public static Task<T?> GetJsonAsync<T>(this HttpClient client, string url)
=> client.GetFromJsonAsync<T>(url, Options);
public static Task<T?> ReadJsonAsync<T>(this HttpContent content)
=> content.ReadFromJsonAsync<T>(Options);
}
@@ -9,7 +9,7 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture<LudosApiFacto
{
private static async Task SeedAsync(HttpClient client)
{
await client.PostAsJsonAsync("/api/games", new
await client.PostJsonAsync("/api/games", new
{
title = "Chrono Trigger",
system = "SNES",
@@ -20,7 +20,7 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture<LudosApiFacto
played = true,
finished = true,
});
await client.PostAsJsonAsync("/api/games", new
await client.PostJsonAsync("/api/games", new
{
title = "Ico",
system = "PS2",
@@ -54,7 +54,7 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture<LudosApiFacto
Assert.Contains("attachment", response.Content.Headers.ContentDisposition?.DispositionType
?? response.Content.Headers.ContentDisposition?.ToString() ?? "attachment");
var payload = await response.Content.ReadFromJsonAsync<LibraryExport>();
var payload = await response.Content.ReadJsonAsync<LibraryExport>();
Assert.Equal(2, payload!.Count);
Assert.Contains(payload.Games, g => g.Title == "Chrono Trigger" && g.Developer == "Square");
}
@@ -79,9 +79,9 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture<LudosApiFacto
var alice = await factory.CreateUserClientAsync("exp-alice");
var bob = await factory.CreateUserClientAsync("exp-bob");
await SeedAsync(alice);
await bob.PostAsJsonAsync("/api/games", new { title = "Bob Only", system = "N64", own = true });
await bob.PostJsonAsync("/api/games", new { title = "Bob Only", system = "N64", own = true });
var payload = await bob.GetFromJsonAsync<LibraryExport>("/api/library/export?format=json");
var payload = await bob.GetJsonAsync<LibraryExport>("/api/library/export?format=json");
Assert.Equal(1, payload!.Count);
Assert.Equal("Bob Only", payload.Games[0].Title);
@@ -109,8 +109,8 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture<LudosApiFacto
FileContent(exported, "library.json", "application/json"));
response.EnsureSuccessStatusCode();
var before = await source.GetFromJsonAsync<LibraryExport>("/api/library/export?format=json");
var after = await target.GetFromJsonAsync<LibraryExport>("/api/library/export?format=json");
var before = await source.GetJsonAsync<LibraryExport>("/api/library/export?format=json");
var after = await target.GetJsonAsync<LibraryExport>("/api/library/export?format=json");
Assert.Equal(before!.Count, after!.Count);
Assert.Equal(
@@ -133,14 +133,14 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture<LudosApiFacto
""";
var result = await (await client.PostAsync("/api/library/import",
FileContent(csv, "in.csv", "text/csv"))).Content.ReadFromJsonAsync<ImportResult>();
FileContent(csv, "in.csv", "text/csv"))).Content.ReadJsonAsync<ImportResult>();
Assert.Equal(1, result!.Created); // Super Metroid
Assert.Equal(1, result.Updated); // Chrono Trigger matched on title + system
Assert.Equal(0, result.Deleted);
// The update took effect: it was finished before, and the file says otherwise.
var page = await client.GetFromJsonAsync<PagePayload>("/api/games?search=Chrono");
var page = await client.GetJsonAsync<PagePayload>("/api/games?search=Chrono");
Assert.False(page!.Items[0].Finished);
}
@@ -148,7 +148,7 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture<LudosApiFacto
public async Task The_same_title_on_a_different_system_is_a_different_game()
{
var client = await factory.CreateUserClientAsync("imp-platform");
await client.PostAsJsonAsync("/api/games", new
await client.PostJsonAsync("/api/games", new
{
title = "Donkey Kong Country", system = "SNES", own = true,
});
@@ -161,7 +161,7 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture<LudosApiFacto
""";
var result = await (await client.PostAsync("/api/library/import",
FileContent(csv, "in.csv", "text/csv"))).Content.ReadFromJsonAsync<ImportResult>();
FileContent(csv, "in.csv", "text/csv"))).Content.ReadJsonAsync<ImportResult>();
Assert.Equal(2, result!.Created); // GB and GBA
Assert.Equal(1, result.Updated); // the existing SNES row
@@ -179,12 +179,12 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture<LudosApiFacto
""";
var result = await (await client.PostAsync("/api/library/import?dryRun=true",
FileContent(csv, "in.csv", "text/csv"))).Content.ReadFromJsonAsync<ImportResult>();
FileContent(csv, "in.csv", "text/csv"))).Content.ReadJsonAsync<ImportResult>();
Assert.True(result!.DryRun);
Assert.Equal(1, result.Created);
var page = await client.GetFromJsonAsync<PagePayload>("/api/games");
var page = await client.GetJsonAsync<PagePayload>("/api/games");
Assert.Equal(2, page!.Total); // still just the seeded pair
}
@@ -200,12 +200,12 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture<LudosApiFacto
""";
var result = await (await client.PostAsync("/api/library/import?mode=Replace",
FileContent(csv, "in.csv", "text/csv"))).Content.ReadFromJsonAsync<ImportResult>();
FileContent(csv, "in.csv", "text/csv"))).Content.ReadJsonAsync<ImportResult>();
Assert.Equal(2, result!.Deleted);
Assert.Equal(1, result.Created);
var page = await client.GetFromJsonAsync<PagePayload>("/api/games");
var page = await client.GetJsonAsync<PagePayload>("/api/games");
Assert.Equal(1, page!.Total);
Assert.Equal("Only Survivor", page.Items[0].Title);
}
@@ -222,7 +222,7 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture<LudosApiFacto
await bob.PostAsync("/api/library/import?mode=Replace",
FileContent("title,system,own\nBob Only,GC,true", "in.csv", "text/csv"));
var alicePage = await alice.GetFromJsonAsync<PagePayload>("/api/games");
var alicePage = await alice.GetJsonAsync<PagePayload>("/api/games");
Assert.Equal(2, alicePage!.Total);
}
@@ -238,7 +238,7 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture<LudosApiFacto
""";
var result = await (await client.PostAsync("/api/library/import",
FileContent(csv, "in.csv", "text/csv"))).Content.ReadFromJsonAsync<ImportResult>();
FileContent(csv, "in.csv", "text/csv"))).Content.ReadJsonAsync<ImportResult>();
Assert.Equal(1, result!.Created);
Assert.Single(result.Errors);
@@ -251,7 +251,7 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture<LudosApiFacto
var client = await factory.CreateUserClientAsync("imp-quoting");
var awkward = "A description with, a comma, \"quotes\" and\na newline.";
await client.PostAsJsonAsync("/api/games", new
await client.PostJsonAsync("/api/games", new
{
title = "Awkward, Game \"Title\"",
system = "PS1",
@@ -265,7 +265,7 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture<LudosApiFacto
var target = await factory.CreateUserClientAsync("imp-quoting-target");
await target.PostAsync("/api/library/import", FileContent(csv, "in.csv", "text/csv"));
var payload = await target.GetFromJsonAsync<LibraryExport>("/api/library/export?format=json");
var payload = await target.GetJsonAsync<LibraryExport>("/api/library/export?format=json");
var game = Assert.Single(payload!.Games);
Assert.Equal("Awkward, Game \"Title\"", game.Title);
@@ -282,7 +282,7 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture<LudosApiFacto
""";
var result = await (await client.PostAsync("/api/library/import",
FileContent(json, "in.json", "application/json"))).Content.ReadFromJsonAsync<ImportResult>();
FileContent(json, "in.json", "application/json"))).Content.ReadJsonAsync<ImportResult>();
Assert.Equal(1, result!.Created);
}
@@ -41,7 +41,7 @@ public class LudosApiFactory : WebApplicationFactory<Program>, IAsyncLifetime
public async Task<HttpClient> CreateUserClientAsync(string userName)
{
var client = CreateClient();
var response = await client.PostAsJsonAsync("/api/auth/register", new
var response = await client.PostJsonAsync("/api/auth/register", new
{
userName,
email = $"{userName}@example.test",
@@ -49,7 +49,7 @@ public class LudosApiFactory : WebApplicationFactory<Program>, IAsyncLifetime
});
response.EnsureSuccessStatusCode();
var auth = await response.Content.ReadFromJsonAsync<AuthPayload>();
var auth = await response.Content.ReadJsonAsync<AuthPayload>();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", auth!.Token);
@@ -28,9 +28,9 @@ public class OwnershipTests(LudosApiFactory factory) : IClassFixture<LudosApiFac
private static async Task<int> CreateGameAsync(HttpClient client, string title)
{
var response = await client.PostAsJsonAsync("/api/games", Game(title));
var response = await client.PostJsonAsync("/api/games", Game(title));
response.EnsureSuccessStatusCode();
var created = await response.Content.ReadFromJsonAsync<GamePayload>();
var created = await response.Content.ReadJsonAsync<GamePayload>();
return created!.Id;
}
@@ -43,8 +43,8 @@ public class OwnershipTests(LudosApiFactory factory) : IClassFixture<LudosApiFac
await CreateGameAsync(alice, "Alice's Game");
await CreateGameAsync(bob, "Bob's Game");
var alicePage = await alice.GetFromJsonAsync<PagePayload>("/api/games");
var bobPage = await bob.GetFromJsonAsync<PagePayload>("/api/games");
var alicePage = await alice.GetJsonAsync<PagePayload>("/api/games");
var bobPage = await bob.GetJsonAsync<PagePayload>("/api/games");
Assert.Single(alicePage!.Items);
Assert.Equal("Alice's Game", alicePage.Items[0].Title);
@@ -73,11 +73,11 @@ public class OwnershipTests(LudosApiFactory factory) : IClassFixture<LudosApiFac
var bob = await factory.CreateUserClientAsync("upd-bob");
var aliceGame = await CreateGameAsync(alice, "Untouched");
var response = await bob.PutAsJsonAsync($"/api/games/{aliceGame}", Game("Hijacked"));
var response = await bob.PutJsonAsync($"/api/games/{aliceGame}", Game("Hijacked"));
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
var after = await alice.GetFromJsonAsync<GamePayload>($"/api/games/{aliceGame}");
var after = await alice.GetJsonAsync<GamePayload>($"/api/games/{aliceGame}");
Assert.Equal("Untouched", after!.Title);
}
@@ -102,11 +102,11 @@ public class OwnershipTests(LudosApiFactory factory) : IClassFixture<LudosApiFac
var alice = await factory.CreateUserClientAsync("spoof-alice");
var bob = await factory.CreateUserClientAsync("spoof-bob");
var bobUser = await bob.GetFromJsonAsync<LudosApiFactory.UserPayload>("/api/auth/me");
var bobUser = await bob.GetJsonAsync<LudosApiFactory.UserPayload>("/api/auth/me");
// Alice creates a game while claiming it belongs to Bob. The contract has
// no ownerId, so this should be ignored rather than honoured.
var response = await alice.PostAsJsonAsync("/api/games", new
var response = await alice.PostJsonAsync("/api/games", new
{
title = "Attempted Handover",
system = "SNES",
@@ -116,8 +116,8 @@ public class OwnershipTests(LudosApiFactory factory) : IClassFixture<LudosApiFac
});
response.EnsureSuccessStatusCode();
var alicePage = await alice.GetFromJsonAsync<PagePayload>("/api/games");
var bobPage = await bob.GetFromJsonAsync<PagePayload>("/api/games");
var alicePage = await alice.GetJsonAsync<PagePayload>("/api/games");
var bobPage = await bob.GetJsonAsync<PagePayload>("/api/games");
Assert.Single(alicePage!.Items);
Assert.Empty(bobPage!.Items);
@@ -129,10 +129,10 @@ public class OwnershipTests(LudosApiFactory factory) : IClassFixture<LudosApiFac
var alice = await factory.CreateUserClientAsync("facet-alice");
var bob = await factory.CreateUserClientAsync("facet-bob");
await alice.PostAsJsonAsync("/api/games", new { title = "A", system = "N64", genre = "fps", own = true });
await bob.PostAsJsonAsync("/api/games", new { title = "B", system = "PS2", genre = "rpg", own = true });
await alice.PostJsonAsync("/api/games", new { title = "A", system = "N64", genre = "fps", own = true });
await bob.PostJsonAsync("/api/games", new { title = "B", system = "PS2", genre = "rpg", own = true });
var facets = await alice.GetFromJsonAsync<FacetsPayload>("/api/games/facets");
var facets = await alice.GetJsonAsync<FacetsPayload>("/api/games/facets");
Assert.Equal(["N64"], facets!.Systems);
Assert.Equal(["fps"], facets.Genres);
@@ -147,9 +147,9 @@ public class OwnershipTests(LudosApiFactory factory) : IClassFixture<LudosApiFac
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync("/api/games/1")).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync("/api/games/facets")).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized,
(await anonymous.PostAsJsonAsync("/api/games", Game("x"))).StatusCode);
(await anonymous.PostJsonAsync("/api/games", Game("x"))).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized,
(await anonymous.PutAsJsonAsync("/api/games/1", Game("x"))).StatusCode);
(await anonymous.PutJsonAsync("/api/games/1", Game("x"))).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.DeleteAsync("/api/games/1")).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.PostAsync("/api/images", null)).StatusCode);
}