From 04745cdd0f9b8b5f6d09463e0cf0bcebfdffcdd9 Mon Sep 17 00:00:00 2001 From: Mohannad Ehab <157999295+MohanEhab@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:21:01 +0300 Subject: [PATCH 1/3] feat(module-04-games): add game catalog domain, schema, and seeder Adds Game/GameTag/GameModeCapability/UserFavoriteGame/CatalogSeedHistory entities, EF configurations, two additive migrations, and the checksum-verified seed manifest + advisory-locked seeder CLI for the canonical 8-game catalog. --- src/SimPle.Domain/Games/CatalogSeedHistory.cs | 20 + src/SimPle.Domain/Games/Game.cs | 311 ++++- src/SimPle.Domain/Games/GameModeCapability.cs | 18 + src/SimPle.Domain/Games/GameTag.cs | 18 + src/SimPle.Domain/Games/UserFavoriteGame.cs | 43 + .../DependencyInjection.cs | 1 + .../Games/CatalogSeedManifest.cs | 73 + .../Games/GameCatalogSeeder.cs | 267 ++++ .../Games/catalog.seed.schema.json | 74 + .../Games/catalog.seed.v1.json | 173 +++ .../20260710072312_AddGameCatalog.Designer.cs | 1184 ++++++++++++++++ .../20260710072312_AddGameCatalog.cs | 218 +++ ...073819_AddGameLifecycleVersion.Designer.cs | 1187 +++++++++++++++++ .../20260710073819_AddGameLifecycleVersion.cs | 29 + .../Migrations/AppDbContextModelSnapshot.cs | 277 ++++ .../Persistence/AppDbContext.cs | 8 + .../CatalogSeedHistoryConfiguration.cs | 20 + .../Configurations/GameConfiguration.cs | 64 + .../GameModeCapabilityConfiguration.cs | 18 + .../Configurations/GameTagConfiguration.cs | 18 + .../UserFavoriteGameConfiguration.cs | 23 + .../SimPle.Infrastructure.csproj | 5 + 22 files changed, 4024 insertions(+), 25 deletions(-) create mode 100644 src/SimPle.Domain/Games/CatalogSeedHistory.cs create mode 100644 src/SimPle.Domain/Games/GameModeCapability.cs create mode 100644 src/SimPle.Domain/Games/GameTag.cs create mode 100644 src/SimPle.Domain/Games/UserFavoriteGame.cs create mode 100644 src/SimPle.Infrastructure/Games/CatalogSeedManifest.cs create mode 100644 src/SimPle.Infrastructure/Games/GameCatalogSeeder.cs create mode 100644 src/SimPle.Infrastructure/Games/catalog.seed.schema.json create mode 100644 src/SimPle.Infrastructure/Games/catalog.seed.v1.json create mode 100644 src/SimPle.Infrastructure/Migrations/20260710072312_AddGameCatalog.Designer.cs create mode 100644 src/SimPle.Infrastructure/Migrations/20260710072312_AddGameCatalog.cs create mode 100644 src/SimPle.Infrastructure/Migrations/20260710073819_AddGameLifecycleVersion.Designer.cs create mode 100644 src/SimPle.Infrastructure/Migrations/20260710073819_AddGameLifecycleVersion.cs create mode 100644 src/SimPle.Infrastructure/Persistence/Configurations/CatalogSeedHistoryConfiguration.cs create mode 100644 src/SimPle.Infrastructure/Persistence/Configurations/GameConfiguration.cs create mode 100644 src/SimPle.Infrastructure/Persistence/Configurations/GameModeCapabilityConfiguration.cs create mode 100644 src/SimPle.Infrastructure/Persistence/Configurations/GameTagConfiguration.cs create mode 100644 src/SimPle.Infrastructure/Persistence/Configurations/UserFavoriteGameConfiguration.cs diff --git a/src/SimPle.Domain/Games/CatalogSeedHistory.cs b/src/SimPle.Domain/Games/CatalogSeedHistory.cs new file mode 100644 index 0000000..7df8b90 --- /dev/null +++ b/src/SimPle.Domain/Games/CatalogSeedHistory.cs @@ -0,0 +1,20 @@ +using SimPle.Domain.Common; + +namespace SimPle.Domain.Games; + +/// Records that a given catalog manifest version was applied, with its content checksum. +public class CatalogSeedHistory : Entity +{ + public string ManifestVersion { get; private set; } = default!; + public string Checksum { get; private set; } = default!; + public DateTime AppliedAtUtc { get; private set; } + + private CatalogSeedHistory() { } + + public static CatalogSeedHistory Record(string manifestVersion, string checksum) => new() + { + ManifestVersion = manifestVersion, + Checksum = checksum, + AppliedAtUtc = DateTime.UtcNow, + }; +} diff --git a/src/SimPle.Domain/Games/Game.cs b/src/SimPle.Domain/Games/Game.cs index 09586be..2bdbcc6 100644 --- a/src/SimPle.Domain/Games/Game.cs +++ b/src/SimPle.Domain/Games/Game.cs @@ -3,42 +3,303 @@ namespace SimPle.Domain.Games; /// -/// Catalog entry for a game. The actual game logic lives in the GameHost module. +/// Catalog entry for a game. The actual game logic lives in the GameHost module. This slice (4A) owns only +/// the domain/schema shape; the read API, search/cursor, favorites endpoints and outbox emission are 4B. /// public class Game : Entity { + private readonly List _tags = new(); + private readonly List _capabilities = new(); + public string Slug { get; private set; } = default!; public string Name { get; private set; } = default!; - public string Description { get; private set; } = default!; - public string Rules { get; private set; } = default!; + public string Summary { get; private set; } = default!; + public string RulesSummary { get; private set; } = default!; public string Category { get; private set; } = default!; - public string Difficulty { get; private set; } = "Medium"; - public string EstimatedDuration { get; private set; } = "5-10 min"; - public int MinPlayers { get; private set; } = 1; - public int MaxPlayers { get; private set; } = 2; - public bool SupportsSolo { get; private set; } = true; - public bool SupportsAi { get; private set; } = true; - public bool SupportsMultiplayer { get; private set; } = true; - public bool SupportsRanked { get; private set; } = true; - public bool IsActive { get; private set; } = true; - public bool IsFeatured { get; private set; } + public GameDifficulty Difficulty { get; private set; } + public int EstimatedDurationMinMinutes { get; private set; } + public int EstimatedDurationMaxMinutes { get; private set; } + public int MinPlayers { get; private set; } + public int MaxPlayers { get; private set; } + public GameLifecycle Lifecycle { get; private set; } + + /// + /// Monotonic per-game counter bumped on every lifecycle transition. Starts at 1 (the as-created state); + /// used as the outbox AggregateDomainVersion for GameLifecycleChangedV1 so a retried + /// transition is idempotent while two distinct transitions never collide, even across an + /// Available <-> Maintenance cycle. Mirrors . + /// + public int LifecycleVersion { get; private set; } = 1; + + public int? FeaturedRank { get; private set; } public int SortOrder { get; private set; } + public string ArtToken { get; private set; } = default!; + public string ArtColorA { get; private set; } = default!; + public string ArtColorB { get; private set; } = default!; + public string ArtAltText { get; private set; } = default!; + + /// Last manifest version that wrote this row (seeder bookkeeping only). + public string ManifestVersion { get; private set; } = default!; - public IReadOnlyList Tags { get; private set; } = []; - public IReadOnlyList AiDifficultyLevels { get; private set; } = ["Easy", "Medium", "Hard"]; + public uint Version { get; private set; } // mapped to xmin via IsRowVersion() in EF config + + public IReadOnlyList Tags => _tags; + public IReadOnlyList Capabilities => _capabilities; private Game() { } public static Game Create( - string slug, string name, string description, string rules, - string category, int minPlayers, int maxPlayers) => new() - { - Slug = slug, - Name = name, - Description = description, - Rules = rules, - Category = category, - MinPlayers = minPlayers, - MaxPlayers = maxPlayers, + string slug, + string name, + string summary, + string rulesSummary, + GameDifficulty difficulty, + int estimatedDurationMinMinutes, + int estimatedDurationMaxMinutes, + int minPlayers, + int maxPlayers, + GameLifecycle initialLifecycle, + int? featuredRank, + int sortOrder, + string artToken, + string artColorA, + string artColorB, + string artAltText, + string manifestVersion, + string category, + IEnumerable tags, + IEnumerable modes) + { + var game = new Game + { + Slug = RequireNonEmpty(slug, nameof(slug)), + Name = RequireNonEmpty(name, nameof(name)), + Summary = RequireNonEmpty(summary, nameof(summary)), + RulesSummary = RequireNonEmpty(rulesSummary, nameof(rulesSummary)), + Category = RequireValidCategory(category), + Difficulty = difficulty, + EstimatedDurationMinMinutes = estimatedDurationMinMinutes, + EstimatedDurationMaxMinutes = estimatedDurationMaxMinutes, + MinPlayers = minPlayers, + MaxPlayers = maxPlayers, + Lifecycle = initialLifecycle, + FeaturedRank = featuredRank, + SortOrder = sortOrder, + ArtToken = RequireNonEmpty(artToken, nameof(artToken)), + ArtColorA = RequireNonEmpty(artColorA, nameof(artColorA)), + ArtColorB = RequireNonEmpty(artColorB, nameof(artColorB)), + ArtAltText = RequireNonEmpty(artAltText, nameof(artAltText)), + ManifestVersion = RequireNonEmpty(manifestVersion, nameof(manifestVersion)), + }; + + game.Validate(); + game.ReplaceTags(tags); + game.ReplaceModes(modes); + game.ValidateModesImplyMultiplayer(); + + return game; + } + + /// + /// Manifest upgrade path used by the seeder: Lifecycle and Slug are never changed here. Re-validates all + /// invariants and replaces the tag/mode child collections wholesale. + /// + public void ApplyManifestUpdate( + string name, + string summary, + string rulesSummary, + GameDifficulty difficulty, + int estimatedDurationMinMinutes, + int estimatedDurationMaxMinutes, + int minPlayers, + int maxPlayers, + int? featuredRank, + int sortOrder, + string artToken, + string artColorA, + string artColorB, + string artAltText, + string manifestVersion, + string category, + IEnumerable tags, + IEnumerable modes) + { + Name = RequireNonEmpty(name, nameof(name)); + Summary = RequireNonEmpty(summary, nameof(summary)); + RulesSummary = RequireNonEmpty(rulesSummary, nameof(rulesSummary)); + Category = RequireValidCategory(category); + Difficulty = difficulty; + EstimatedDurationMinMinutes = estimatedDurationMinMinutes; + EstimatedDurationMaxMinutes = estimatedDurationMaxMinutes; + MinPlayers = minPlayers; + MaxPlayers = maxPlayers; + FeaturedRank = featuredRank; + SortOrder = sortOrder; + ArtToken = RequireNonEmpty(artToken, nameof(artToken)); + ArtColorA = RequireNonEmpty(artColorA, nameof(artColorA)); + ArtColorB = RequireNonEmpty(artColorB, nameof(artColorB)); + ArtAltText = RequireNonEmpty(artAltText, nameof(artAltText)); + ManifestVersion = RequireNonEmpty(manifestVersion, nameof(manifestVersion)); + + Validate(); + ReplaceTags(tags); + ReplaceModes(modes); + ValidateModesImplyMultiplayer(); + + Touch(); + } + + // ── Lifecycle transitions ─────────────────────────────────────────────── + + public void Publish() + { + if (Lifecycle != GameLifecycle.Draft) + throw new InvalidOperationException($"Cannot publish (Draft -> ComingSoon) from {Lifecycle}."); + Lifecycle = GameLifecycle.ComingSoon; + LifecycleVersion += 1; + Touch(); + } + + public void MakeAvailable() + { + if (Lifecycle != GameLifecycle.ComingSoon && Lifecycle != GameLifecycle.Maintenance) + throw new InvalidOperationException($"Cannot transition to Available from {Lifecycle}."); + Lifecycle = GameLifecycle.Available; + LifecycleVersion += 1; + Touch(); + } + + public void EnterMaintenance() + { + if (Lifecycle != GameLifecycle.Available) + throw new InvalidOperationException($"Cannot transition to Maintenance from {Lifecycle}."); + Lifecycle = GameLifecycle.Maintenance; + LifecycleVersion += 1; + Touch(); + } + + public void Retire() + { + if (Lifecycle == GameLifecycle.Retired) + throw new InvalidOperationException("Retired is terminal and cannot be re-retired."); + Lifecycle = GameLifecycle.Retired; + FeaturedRank = null; + LifecycleVersion += 1; + Touch(); + } + + /// Generic entry point enforcing the exact allowed-edges table; delegates to the named methods. + public void TransitionTo(GameLifecycle target) + { + switch (target) + { + case GameLifecycle.ComingSoon: + Publish(); + break; + case GameLifecycle.Available: + MakeAvailable(); + break; + case GameLifecycle.Maintenance: + EnterMaintenance(); + break; + case GameLifecycle.Retired: + Retire(); + break; + default: + throw new InvalidOperationException($"Cannot transition to {target}."); + } + } + + // ── Validation ─────────────────────────────────────────────────────────── + + private void Validate() + { + if (MinPlayers < 1) + throw new ArgumentException("MinPlayers must be at least 1.", nameof(MinPlayers)); + if (MinPlayers > MaxPlayers) + throw new ArgumentException("MinPlayers must be <= MaxPlayers.", nameof(MinPlayers)); + if (EstimatedDurationMinMinutes > EstimatedDurationMaxMinutes) + throw new ArgumentException( + "EstimatedDurationMinMinutes must be <= EstimatedDurationMaxMinutes.", + nameof(EstimatedDurationMinMinutes)); + if (FeaturedRank is not null && (Lifecycle == GameLifecycle.Draft || Lifecycle == GameLifecycle.Retired)) + throw new ArgumentException("FeaturedRank may only be set when Lifecycle is not Draft or Retired."); + } + + private void ReplaceTags(IEnumerable tags) + { + var values = tags.ToList(); + if (values.Distinct(StringComparer.Ordinal).Count() != values.Count) + throw new ArgumentException("Tag values must not contain duplicates.", nameof(tags)); + foreach (var value in values) + { + if (!GameCatalogAllowLists.Tags.Contains(value)) + throw new ArgumentException($"Tag '{value}' is not in the allow-list.", nameof(tags)); + if (value == Category) + throw new ArgumentException($"Tag '{value}' duplicates the category and must not be repeated.", nameof(tags)); + } + + _tags.Clear(); + foreach (var value in values) + _tags.Add(GameTag.Create(Id, value)); + } + + private static string RequireValidCategory(string category) + { + RequireNonEmpty(category, nameof(category)); + if (!GameCatalogAllowLists.Tags.Contains(category)) + throw new ArgumentException($"Category '{category}' is not in the allow-list.", nameof(category)); + return category; + } + + private void ReplaceModes(IEnumerable modes) + { + var values = modes.ToList(); + if (values.Distinct(StringComparer.Ordinal).Count() != values.Count) + throw new ArgumentException("Mode values must not contain duplicates.", nameof(modes)); + foreach (var value in values) + { + if (!GameCatalogAllowLists.Modes.Contains(value)) + throw new ArgumentException($"Mode '{value}' is not in the allow-list.", nameof(modes)); + } + + _capabilities.Clear(); + foreach (var value in values) + _capabilities.Add(GameModeCapability.Create(Id, value)); + } + + private void ValidateModesImplyMultiplayer() + { + var modeValues = _capabilities.Select(c => c.Mode).ToHashSet(StringComparer.Ordinal); + if (modeValues.Contains("ranked") && !modeValues.Contains("multiplayer")) + throw new ArgumentException("Mode 'ranked' requires 'multiplayer' to also be present."); + } + + private static string RequireNonEmpty(string value, string paramName) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException($"{paramName} must not be empty.", paramName); + return value; + } +} + +public enum GameDifficulty { Easy, Medium, Hard } + +public enum GameLifecycle { Draft, ComingSoon, Available, Maintenance, Retired } + +/// +/// Phase-1 allow-lists for game tags and mode capabilities. Used by domain validation on +/// creation/update and by the seeder/manifest validator before any database write. +/// +public static class GameCatalogAllowLists +{ + public static readonly IReadOnlySet Tags = new HashSet(StringComparer.Ordinal) + { + "puzzle", "logic", "arcade", "reaction", "strategy", "classic", "vocabulary", "memory", + }; + + public static readonly IReadOnlySet Modes = new HashSet(StringComparer.Ordinal) + { + "solo", "cooperative", "multiplayer", "ai", "ranked", "quick-match", }; } diff --git a/src/SimPle.Domain/Games/GameModeCapability.cs b/src/SimPle.Domain/Games/GameModeCapability.cs new file mode 100644 index 0000000..094f038 --- /dev/null +++ b/src/SimPle.Domain/Games/GameModeCapability.cs @@ -0,0 +1,18 @@ +using SimPle.Domain.Common; + +namespace SimPle.Domain.Games; + +/// Child row of ; constructible only via the owning . +public class GameModeCapability : Entity +{ + public Guid GameId { get; private set; } + public string Mode { get; private set; } = default!; + + private GameModeCapability() { } + + internal static GameModeCapability Create(Guid gameId, string mode) => new() + { + GameId = gameId, + Mode = mode, + }; +} diff --git a/src/SimPle.Domain/Games/GameTag.cs b/src/SimPle.Domain/Games/GameTag.cs new file mode 100644 index 0000000..1b1cbdc --- /dev/null +++ b/src/SimPle.Domain/Games/GameTag.cs @@ -0,0 +1,18 @@ +using SimPle.Domain.Common; + +namespace SimPle.Domain.Games; + +/// Child row of ; constructible only via the owning . +public class GameTag : Entity +{ + public Guid GameId { get; private set; } + public string Value { get; private set; } = default!; + + private GameTag() { } + + internal static GameTag Create(Guid gameId, string value) => new() + { + GameId = gameId, + Value = value, + }; +} diff --git a/src/SimPle.Domain/Games/UserFavoriteGame.cs b/src/SimPle.Domain/Games/UserFavoriteGame.cs new file mode 100644 index 0000000..9568c1e --- /dev/null +++ b/src/SimPle.Domain/Games/UserFavoriteGame.cs @@ -0,0 +1,43 @@ +using SimPle.Domain.Common; + +namespace SimPle.Domain.Games; + +/// +/// A user's favorite marker for a game. increments on each fresh +/// favorite -> unfavorite -> favorite cycle, giving the future (4B) outbox event a fresh +/// AggregateDomainVersion per cycle. No 4A code creates these rows via an API — schema/domain only. +/// +public class UserFavoriteGame : Entity +{ + public Guid UserId { get; private set; } + public Guid GameId { get; private set; } + public bool IsActive { get; private set; } + public int CycleId { get; private set; } + + private UserFavoriteGame() { } + + public static UserFavoriteGame Favorite(Guid userId, Guid gameId) => new() + { + UserId = userId, + GameId = gameId, + IsActive = true, + CycleId = 1, + }; + + /// Idempotent: a no-op if already inactive. Does not increment CycleId. + public void Unfavorite() + { + if (!IsActive) return; + IsActive = false; + Touch(); + } + + /// Idempotent: a no-op if already active. Increments CycleId for a fresh cycle. + public void Refavorite() + { + if (IsActive) return; + IsActive = true; + CycleId += 1; + Touch(); + } +} diff --git a/src/SimPle.Infrastructure/DependencyInjection.cs b/src/SimPle.Infrastructure/DependencyInjection.cs index 04b217a..a9e297f 100644 --- a/src/SimPle.Infrastructure/DependencyInjection.cs +++ b/src/SimPle.Infrastructure/DependencyInjection.cs @@ -41,6 +41,7 @@ public static IServiceCollection AddInfrastructureServices( services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.Configure(configuration.GetSection(StorageOptions.SectionName)); services.PostConfigure(options => { diff --git a/src/SimPle.Infrastructure/Games/CatalogSeedManifest.cs b/src/SimPle.Infrastructure/Games/CatalogSeedManifest.cs new file mode 100644 index 0000000..57200df --- /dev/null +++ b/src/SimPle.Infrastructure/Games/CatalogSeedManifest.cs @@ -0,0 +1,73 @@ +using System.Text.Json.Serialization; + +namespace SimPle.Infrastructure.Games; + +/// Deserialization shape of the embedded catalog.seed.v1.json manifest. +public sealed class CatalogSeedManifest +{ + [JsonPropertyName("manifestVersion")] + public string ManifestVersion { get; set; } = default!; + + [JsonPropertyName("games")] + public List Games { get; set; } = new(); +} + +public sealed class CatalogSeedGameEntry +{ + [JsonPropertyName("legacyMockId")] + public string LegacyMockId { get; set; } = default!; + + [JsonPropertyName("slug")] + public string Slug { get; set; } = default!; + + [JsonPropertyName("name")] + public string Name { get; set; } = default!; + + [JsonPropertyName("summary")] + public string Summary { get; set; } = default!; + + [JsonPropertyName("rulesSummary")] + public string RulesSummary { get; set; } = default!; + + [JsonPropertyName("category")] + public string Category { get; set; } = default!; + + [JsonPropertyName("tags")] + public List Tags { get; set; } = new(); + + [JsonPropertyName("difficulty")] + public string Difficulty { get; set; } = default!; + + [JsonPropertyName("estimatedDurationMinMinutes")] + public int EstimatedDurationMinMinutes { get; set; } + + [JsonPropertyName("estimatedDurationMaxMinutes")] + public int EstimatedDurationMaxMinutes { get; set; } + + [JsonPropertyName("minPlayers")] + public int MinPlayers { get; set; } + + [JsonPropertyName("maxPlayers")] + public int MaxPlayers { get; set; } + + [JsonPropertyName("modes")] + public List Modes { get; set; } = new(); + + [JsonPropertyName("featuredRank")] + public int? FeaturedRank { get; set; } + + [JsonPropertyName("sortOrder")] + public int SortOrder { get; set; } + + [JsonPropertyName("artToken")] + public string ArtToken { get; set; } = default!; + + [JsonPropertyName("artColorA")] + public string ArtColorA { get; set; } = default!; + + [JsonPropertyName("artColorB")] + public string ArtColorB { get; set; } = default!; + + [JsonPropertyName("artAltText")] + public string ArtAltText { get; set; } = default!; +} diff --git a/src/SimPle.Infrastructure/Games/GameCatalogSeeder.cs b/src/SimPle.Infrastructure/Games/GameCatalogSeeder.cs new file mode 100644 index 0000000..ca83325 --- /dev/null +++ b/src/SimPle.Infrastructure/Games/GameCatalogSeeder.cs @@ -0,0 +1,267 @@ +using System.Reflection; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using SimPle.Domain.Games; +using SimPle.Infrastructure.Persistence; + +namespace SimPle.Infrastructure.Games; + +public sealed record GameCatalogSeedResult(bool Success, string Message, int GamesCreated, int GamesUpdated); + +/// +/// Loads the embedded catalog seed manifest, validates it against domain invariants before touching the +/// database, then upserts the catalog inside a transaction guarded by a Postgres advisory lock so concurrent +/// seeder runs converge safely. See docs/specs/module-04-game-library-discovery-spec.md "Seeder" section. +/// +public sealed class GameCatalogSeeder +{ + // Module-4-specific advisory lock key. Must never collide with another module's advisory lock — + // no other module uses advisory locks today. + private const long CatalogSeedAdvisoryLockKey = 44004001; + + private static readonly string ResourceName = + typeof(GameCatalogSeeder).Assembly.GetManifestResourceNames() + .First(n => n.EndsWith("catalog.seed.v1.json", StringComparison.Ordinal)); + + private readonly AppDbContext _db; + private readonly ILogger _logger; + + public GameCatalogSeeder(AppDbContext db, ILogger logger) + { + _db = db; + _logger = logger; + } + + public async Task SeedAsync(CancellationToken ct = default) + { + byte[] manifestBytes; + await using (var stream = typeof(GameCatalogSeeder).Assembly.GetManifestResourceStream(ResourceName)) + { + if (stream is null) + return Fail("Embedded catalog seed manifest resource not found."); + + using var buffer = new MemoryStream(); + await stream.CopyToAsync(buffer, ct); + manifestBytes = buffer.ToArray(); + } + + CatalogSeedManifest manifest; + try + { + manifest = JsonSerializer.Deserialize(manifestBytes, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) + ?? throw new JsonException("Manifest deserialized to null."); + } + catch (JsonException ex) + { + return Fail($"Manifest failed to parse: {ex.Message}"); + } + + var validationError = Validate(manifest); + if (validationError is not null) + return Fail(validationError); + + var checksum = Convert.ToHexString(SHA256.HashData(manifestBytes)).ToLowerInvariant(); + + await using var transaction = await _db.Database.BeginTransactionAsync(ct); + await _db.Database.ExecuteSqlRawAsync( + $"SELECT pg_advisory_xact_lock({CatalogSeedAdvisoryLockKey});", ct); + + var existingHistory = await _db.Set() + .FirstOrDefaultAsync(h => h.ManifestVersion == manifest.ManifestVersion, ct); + + if (existingHistory is not null) + { + if (existingHistory.Checksum != checksum) + { + await transaction.RollbackAsync(ct); + var message = $"Checksum mismatch for manifest version '{manifest.ManifestVersion}', refusing to overwrite."; + _logger.LogError("Game catalog seed failed: {Message}", message); + return new GameCatalogSeedResult(false, message, 0, 0); + } + + await transaction.CommitAsync(ct); + var noopMessage = $"Manifest version '{manifest.ManifestVersion}' already applied; no-op."; + _logger.LogInformation("Game catalog seed no-op: {Message}", noopMessage); + return new GameCatalogSeedResult(true, noopMessage, 0, 0); + } + + var created = 0; + var updated = 0; + + try + { + foreach (var entry in manifest.Games) + { + var difficulty = Enum.Parse(entry.Difficulty); + + var existingGame = await _db.Games + .Include(g => g.Tags) + .Include(g => g.Capabilities) + .FirstOrDefaultAsync(g => g.Slug == entry.Slug, ct); + + if (existingGame is null) + { + var game = Game.Create( + entry.Slug, + entry.Name, + entry.Summary, + entry.RulesSummary, + difficulty, + entry.EstimatedDurationMinMinutes, + entry.EstimatedDurationMaxMinutes, + entry.MinPlayers, + entry.MaxPlayers, + GameLifecycle.ComingSoon, + entry.FeaturedRank, + entry.SortOrder, + entry.ArtToken, + entry.ArtColorA, + entry.ArtColorB, + entry.ArtAltText, + manifest.ManifestVersion, + entry.Category, + entry.Tags, + entry.Modes); + _db.Games.Add(game); + created++; + } + else + { + existingGame.ApplyManifestUpdate( + entry.Name, + entry.Summary, + entry.RulesSummary, + difficulty, + entry.EstimatedDurationMinMinutes, + entry.EstimatedDurationMaxMinutes, + entry.MinPlayers, + entry.MaxPlayers, + entry.FeaturedRank, + entry.SortOrder, + entry.ArtToken, + entry.ArtColorA, + entry.ArtColorB, + entry.ArtAltText, + manifest.ManifestVersion, + entry.Category, + entry.Tags, + entry.Modes); + updated++; + } + } + + _db.Set().Add(CatalogSeedHistory.Record(manifest.ManifestVersion, checksum)); + + await _db.SaveChangesAsync(ct); + await transaction.CommitAsync(ct); + } + catch (DbUpdateException ex) + { + await transaction.RollbackAsync(ct); + var message = $"Failed to apply manifest version '{manifest.ManifestVersion}': {ex.Message}"; + _logger.LogError(ex, "Game catalog seed failed: {Message}", message); + return new GameCatalogSeedResult(false, message, 0, 0); + } + + var successMessage = + $"Applied manifest version '{manifest.ManifestVersion}': {created} created, {updated} updated."; + _logger.LogInformation("Game catalog seed succeeded: {Message}", successMessage); + return new GameCatalogSeedResult(true, successMessage, created, updated); + } + + private GameCatalogSeedResult Fail(string message) + { + _logger.LogError("Game catalog seed failed: {Message}", message); + return new GameCatalogSeedResult(false, message, 0, 0); + } + + /// + /// Structural + domain-invariant validation of the whole manifest before any database access. + /// Returns null when valid, or a message identifying the offending slug/field otherwise. + /// + private static string? Validate(CatalogSeedManifest manifest) + { + if (string.IsNullOrWhiteSpace(manifest.ManifestVersion)) + return "manifestVersion must not be empty."; + + if (manifest.Games is null || manifest.Games.Count == 0) + return "games must contain at least one entry."; + + var seenSlugs = new HashSet(StringComparer.Ordinal); + var featuredCount = 0; + + foreach (var entry in manifest.Games) + { + if (string.IsNullOrWhiteSpace(entry.Slug)) + return "A game entry is missing a slug."; + if (!seenSlugs.Add(entry.Slug)) + return $"Duplicate slug '{entry.Slug}' in manifest."; + + if (string.IsNullOrWhiteSpace(entry.Name)) + return $"[{entry.Slug}] name must not be empty."; + if (string.IsNullOrWhiteSpace(entry.Summary)) + return $"[{entry.Slug}] summary must not be empty."; + if (string.IsNullOrWhiteSpace(entry.RulesSummary)) + return $"[{entry.Slug}] rulesSummary must not be empty."; + if (string.IsNullOrWhiteSpace(entry.ArtToken)) + return $"[{entry.Slug}] artToken must not be empty."; + if (string.IsNullOrWhiteSpace(entry.ArtColorA)) + return $"[{entry.Slug}] artColorA must not be empty."; + if (string.IsNullOrWhiteSpace(entry.ArtColorB)) + return $"[{entry.Slug}] artColorB must not be empty."; + if (string.IsNullOrWhiteSpace(entry.ArtAltText)) + return $"[{entry.Slug}] artAltText must not be empty."; + + if (!Enum.TryParse(entry.Difficulty, out _)) + return $"[{entry.Slug}] difficulty '{entry.Difficulty}' is not a valid GameDifficulty."; + + if (entry.MinPlayers < 1) + return $"[{entry.Slug}] minPlayers must be at least 1."; + if (entry.MinPlayers > entry.MaxPlayers) + return $"[{entry.Slug}] minPlayers must be <= maxPlayers."; + if (entry.EstimatedDurationMinMinutes > entry.EstimatedDurationMaxMinutes) + return $"[{entry.Slug}] estimatedDurationMinMinutes must be <= estimatedDurationMaxMinutes."; + + if (string.IsNullOrWhiteSpace(entry.Category)) + return $"[{entry.Slug}] category must not be empty."; + if (!GameCatalogAllowLists.Tags.Contains(entry.Category)) + return $"[{entry.Slug}] category '{entry.Category}' is not in the tag allow-list."; + foreach (var tag in entry.Tags) + { + if (!GameCatalogAllowLists.Tags.Contains(tag)) + return $"[{entry.Slug}] tag '{tag}' is not in the allow-list."; + } + if (entry.Tags.Contains(entry.Category)) + return $"[{entry.Slug}] tag '{entry.Category}' duplicates the category and must not be repeated."; + if (entry.Tags.Distinct(StringComparer.Ordinal).Count() != entry.Tags.Count) + return $"[{entry.Slug}] tags contain a duplicate value."; + + if (entry.Modes is null || entry.Modes.Count == 0) + return $"[{entry.Slug}] modes must contain at least one entry."; + foreach (var mode in entry.Modes) + { + if (!GameCatalogAllowLists.Modes.Contains(mode)) + return $"[{entry.Slug}] mode '{mode}' is not in the allow-list."; + } + if (entry.Modes.Distinct(StringComparer.Ordinal).Count() != entry.Modes.Count) + return $"[{entry.Slug}] modes contain a duplicate value."; + if (entry.Modes.Contains("ranked") && !entry.Modes.Contains("multiplayer")) + return $"[{entry.Slug}] mode 'ranked' requires 'multiplayer' to also be present."; + + if (entry.FeaturedRank is not null) + { + if (entry.FeaturedRank != 1) + return $"[{entry.Slug}] featuredRank must be 1 when present."; + featuredCount++; + } + } + + if (featuredCount > 1) + return "At most one game may have a non-null featuredRank."; + + return null; + } +} diff --git a/src/SimPle.Infrastructure/Games/catalog.seed.schema.json b/src/SimPle.Infrastructure/Games/catalog.seed.schema.json new file mode 100644 index 0000000..bf2f99b --- /dev/null +++ b/src/SimPle.Infrastructure/Games/catalog.seed.schema.json @@ -0,0 +1,74 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://simple.internal/schemas/catalog.seed.schema.json", + "title": "SimPle Game Catalog Seed Manifest", + "description": "Documentation-only JSON Schema for catalog.seed.v1.json. Authoritative structural and domain-invariant validation is performed in code by GameCatalogSeeder before any database write.", + "type": "object", + "required": ["manifestVersion", "games"], + "additionalProperties": false, + "properties": { + "manifestVersion": { + "type": "string", + "minLength": 1 + }, + "games": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/gameEntry" } + } + }, + "$defs": { + "gameEntry": { + "type": "object", + "required": [ + "legacyMockId", + "slug", + "name", + "summary", + "rulesSummary", + "category", + "tags", + "difficulty", + "estimatedDurationMinMinutes", + "estimatedDurationMaxMinutes", + "minPlayers", + "maxPlayers", + "modes", + "featuredRank", + "sortOrder", + "artToken", + "artColorA", + "artColorB", + "artAltText" + ], + "additionalProperties": false, + "properties": { + "legacyMockId": { "type": "string", "minLength": 1 }, + "slug": { "type": "string", "minLength": 1 }, + "name": { "type": "string", "minLength": 1 }, + "summary": { "type": "string", "minLength": 1 }, + "rulesSummary": { "type": "string", "minLength": 1 }, + "category": { "type": "string", "minLength": 1 }, + "tags": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "difficulty": { "type": "string", "enum": ["Easy", "Medium", "Hard"] }, + "estimatedDurationMinMinutes": { "type": "integer", "minimum": 0 }, + "estimatedDurationMaxMinutes": { "type": "integer", "minimum": 0 }, + "minPlayers": { "type": "integer", "minimum": 1 }, + "maxPlayers": { "type": "integer", "minimum": 1 }, + "modes": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "featuredRank": { "type": ["integer", "null"] }, + "sortOrder": { "type": "integer", "minimum": 0 }, + "artToken": { "type": "string", "minLength": 1 }, + "artColorA": { "type": "string", "minLength": 1 }, + "artColorB": { "type": "string", "minLength": 1 }, + "artAltText": { "type": "string", "minLength": 1 } + } + } + } +} diff --git a/src/SimPle.Infrastructure/Games/catalog.seed.v1.json b/src/SimPle.Infrastructure/Games/catalog.seed.v1.json new file mode 100644 index 0000000..01b75bf --- /dev/null +++ b/src/SimPle.Infrastructure/Games/catalog.seed.v1.json @@ -0,0 +1,173 @@ +{ + "manifestVersion": "2026.1", + "games": [ + { + "legacyMockId": "sudoku", + "slug": "online-sudoku", + "name": "Online Sudoku", + "summary": "A relaxing numbers puzzle for solo play or a shared cooperative grid.", + "rulesSummary": "Sudoku fills every row, column, and 3×3 box with 1–9.", + "category": "puzzle", + "tags": ["logic"], + "difficulty": "Medium", + "estimatedDurationMinMinutes": 5, + "estimatedDurationMaxMinutes": 20, + "minPlayers": 1, + "maxPlayers": 2, + "modes": ["solo", "cooperative"], + "featuredRank": null, + "sortOrder": 0, + "artToken": "online-sudoku", + "artColorA": "#2D9CDB", + "artColorB": "#56CCF2", + "artAltText": "Online Sudoku abstract game artwork" + }, + { + "legacyMockId": "tetris", + "slug": "falling-blocks-arena", + "name": "Falling Blocks Arena", + "summary": "A fast-paced falling-block stacking challenge built for quick sessions.", + "rulesSummary": "Falling Blocks clears completed lines and, in future multiplayer, sends garbage.", + "category": "arcade", + "tags": ["reaction"], + "difficulty": "Hard", + "estimatedDurationMinMinutes": 3, + "estimatedDurationMaxMinutes": 8, + "minPlayers": 1, + "maxPlayers": 4, + "modes": ["solo", "multiplayer", "quick-match"], + "featuredRank": null, + "sortOrder": 1, + "artToken": "falling-blocks", + "artColorA": "#EB5757", + "artColorB": "#F2994A", + "artAltText": "Falling Blocks Arena abstract game artwork" + }, + { + "legacyMockId": "connect4", + "slug": "four-in-a-row", + "name": "Four in a Row", + "summary": "A classic connection game against the AI or another player.", + "rulesSummary": "Four in a Row wins with four connected tokens.", + "category": "strategy", + "tags": [], + "difficulty": "Easy", + "estimatedDurationMinMinutes": 2, + "estimatedDurationMaxMinutes": 6, + "minPlayers": 2, + "maxPlayers": 2, + "modes": ["ai", "multiplayer", "ranked"], + "featuredRank": null, + "sortOrder": 2, + "artToken": "four-in-a-row", + "artColorA": "#27AE60", + "artColorB": "#F2C94C", + "artAltText": "Four in a Row abstract game artwork" + }, + { + "legacyMockId": "chess", + "slug": "chess-lite", + "name": "Chess Lite", + "summary": "A streamlined chess experience for quick matches against the AI or a rival.", + "rulesSummary": "Chess Lite wins by checkmate or a future configured clock.", + "category": "strategy", + "tags": [], + "difficulty": "Hard", + "estimatedDurationMinMinutes": 5, + "estimatedDurationMaxMinutes": 25, + "minPlayers": 2, + "maxPlayers": 2, + "modes": ["ai", "multiplayer", "ranked"], + "featuredRank": 1, + "sortOrder": 3, + "artToken": "chess-lite", + "artColorA": "#9B51E0", + "artColorB": "#2D9CDB", + "artAltText": "Chess Lite abstract game artwork" + }, + { + "legacyMockId": "checkers", + "slug": "checkers", + "name": "Checkers", + "summary": "A timeless board game of diagonal captures and crowned kings.", + "rulesSummary": "Checkers uses diagonal movement, mandatory captures, and kings.", + "category": "strategy", + "tags": ["classic"], + "difficulty": "Easy", + "estimatedDurationMinMinutes": 5, + "estimatedDurationMaxMinutes": 15, + "minPlayers": 2, + "maxPlayers": 2, + "modes": ["ai", "multiplayer", "ranked"], + "featuredRank": null, + "sortOrder": 4, + "artToken": "checkers", + "artColorA": "#F2C94C", + "artColorB": "#27AE60", + "artAltText": "Checkers abstract game artwork" + }, + { + "legacyMockId": "word", + "slug": "five-letter-duel", + "name": "Five-Letter Duel", + "summary": "A head-to-head word-guessing duel with a five-letter target.", + "rulesSummary": "Five-Letter Duel compares valid five-letter guesses.", + "category": "puzzle", + "tags": ["vocabulary"], + "difficulty": "Medium", + "estimatedDurationMinMinutes": 4, + "estimatedDurationMaxMinutes": 8, + "minPlayers": 2, + "maxPlayers": 2, + "modes": ["multiplayer"], + "featuredRank": null, + "sortOrder": 5, + "artToken": "five-letter", + "artColorA": "#56CCF2", + "artColorB": "#9B51E0", + "artAltText": "Five-Letter Duel abstract game artwork" + }, + { + "legacyMockId": "memory", + "slug": "memory-grid", + "name": "Memory Grid", + "summary": "A pair-matching memory challenge playable solo or with friends.", + "rulesSummary": "Memory Grid matches hidden pairs.", + "category": "puzzle", + "tags": ["memory"], + "difficulty": "Easy", + "estimatedDurationMinMinutes": 3, + "estimatedDurationMaxMinutes": 6, + "minPlayers": 1, + "maxPlayers": 4, + "modes": ["solo", "cooperative", "multiplayer"], + "featuredRank": null, + "sortOrder": 6, + "artToken": "memory-grid", + "artColorA": "#F2994A", + "artColorB": "#EB5757", + "artAltText": "Memory Grid abstract game artwork" + }, + { + "legacyMockId": "snake", + "slug": "snake-rush", + "name": "Snake Rush", + "summary": "A quick-reflex snake game for solo runs or competitive lobbies.", + "rulesSummary": "Snake Rush grows by collecting items and loses on a validated collision.", + "category": "arcade", + "tags": ["reaction"], + "difficulty": "Medium", + "estimatedDurationMinMinutes": 2, + "estimatedDurationMaxMinutes": 5, + "minPlayers": 1, + "maxPlayers": 8, + "modes": ["solo", "multiplayer", "quick-match"], + "featuredRank": null, + "sortOrder": 7, + "artToken": "snake-rush", + "artColorA": "#27AE60", + "artColorB": "#56CCF2", + "artAltText": "Snake Rush abstract game artwork" + } + ] +} diff --git a/src/SimPle.Infrastructure/Migrations/20260710072312_AddGameCatalog.Designer.cs b/src/SimPle.Infrastructure/Migrations/20260710072312_AddGameCatalog.Designer.cs new file mode 100644 index 0000000..8491060 --- /dev/null +++ b/src/SimPle.Infrastructure/Migrations/20260710072312_AddGameCatalog.Designer.cs @@ -0,0 +1,1184 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SimPle.Infrastructure.Persistence; + +#nullable disable + +namespace SimPle.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260710072312_AddGameCatalog")] + partial class AddGameCatalog + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SimPle.Domain.Friends.Block", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BlockedId") + .HasColumnType("uuid"); + + b.Property("BlockerId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("BlockedId"); + + b.HasIndex("BlockerId"); + + b.HasIndex("BlockerId", "BlockedId") + .IsUnique(); + + b.ToTable("blocks", null, t => + { + t.HasCheckConstraint("ck_no_self_block", "\"BlockerId\" != \"BlockedId\""); + }); + }); + + modelBuilder.Entity("SimPle.Domain.Friends.DismissedFriendSuggestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DismissedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SuggestedUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_dismissed_suggestions_expiresat"); + + b.HasIndex("SuggestedUserId"); + + b.HasIndex("UserId", "SuggestedUserId") + .IsUnique() + .HasDatabaseName("ix_dismissed_suggestions_user_suggested"); + + b.ToTable("dismissed_friend_suggestions", null, t => + { + t.HasCheckConstraint("ck_no_self_dismissal", "\"UserId\" != \"SuggestedUserId\""); + }); + }); + + modelBuilder.Entity("SimPle.Domain.Friends.Friendship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AddresseeId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DomainVersion") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L); + + b.Property("EndReason") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSenderId") + .HasColumnType("uuid"); + + b.Property("NextRequestAllowedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestCycleId") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("SendCountInWindow") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("SendWindowStartUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("TransitionActorId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("AddresseeId", "Status", "SentAt", "Id") + .IsDescending(false, false, true, true) + .HasDatabaseName("ix_friendships_addressee_status_sentat_id"); + + b.HasIndex("RequesterId", "Status", "SentAt", "Id") + .IsDescending(false, false, true, true) + .HasDatabaseName("ix_friendships_requester_status_sentat_id"); + + b.ToTable("friendships", null, t => + { + t.HasCheckConstraint("ck_no_self_friendship", "\"RequesterId\" != \"AddresseeId\""); + }); + }); + + modelBuilder.Entity("SimPle.Domain.Friends.UserFriendSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FriendRequestPrivacy") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("FriendsListVisibility") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("PrivacyPolicyVersion") + .HasColumnType("bigint"); + + b.Property("SearchVisibility") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("user_friend_settings", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Games.CatalogSeedHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppliedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Checksum") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ManifestVersion") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ManifestVersion") + .IsUnique(); + + b.ToTable("catalog_seed_history", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Games.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ArtAltText") + .IsRequired() + .HasColumnType("text"); + + b.Property("ArtColorA") + .IsRequired() + .HasColumnType("text"); + + b.Property("ArtColorB") + .IsRequired() + .HasColumnType("text"); + + b.Property("ArtToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("Category") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Difficulty") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("EstimatedDurationMaxMinutes") + .HasColumnType("integer"); + + b.Property("EstimatedDurationMinMinutes") + .HasColumnType("integer"); + + b.Property("FeaturedRank") + .HasColumnType("integer"); + + b.Property("Lifecycle") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ManifestVersion") + .IsRequired() + .HasColumnType("text"); + + b.Property("MaxPlayers") + .HasColumnType("integer"); + + b.Property("MinPlayers") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("RulesSummary") + .IsRequired() + .HasColumnType("text"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("Difficulty", "Slug") + .HasDatabaseName("ix_games_difficulty_slug"); + + b.HasIndex("EstimatedDurationMinMinutes", "Slug") + .HasDatabaseName("ix_games_duration_slug"); + + b.HasIndex("Name", "Slug") + .HasDatabaseName("ix_games_name_slug"); + + b.HasIndex("FeaturedRank", "SortOrder", "Slug") + .HasDatabaseName("ix_games_default_order"); + + b.ToTable("games", null, t => + { + t.HasCheckConstraint("ck_games_draft_retired_not_featured", "(\"Lifecycle\" <> 'Draft' AND \"Lifecycle\" <> 'Retired') OR \"FeaturedRank\" IS NULL"); + + t.HasCheckConstraint("ck_games_duration_bounds", "\"EstimatedDurationMinMinutes\" <= \"EstimatedDurationMaxMinutes\""); + + t.HasCheckConstraint("ck_games_min_players", "\"MinPlayers\" >= 1 AND \"MinPlayers\" <= \"MaxPlayers\""); + }); + }); + + modelBuilder.Entity("SimPle.Domain.Games.GameModeCapability", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GameId") + .HasColumnType("uuid"); + + b.Property("Mode") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("GameId", "Mode") + .IsUnique(); + + b.ToTable("game_mode_capabilities", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Games.GameTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GameId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GameId", "Value") + .IsUnique(); + + b.ToTable("game_tags", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Games.UserFavoriteGame", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CycleId") + .HasColumnType("integer"); + + b.Property("GameId") + .HasColumnType("uuid"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("UserId", "GameId") + .IsUnique(); + + b.ToTable("user_favorite_games", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Outbox.OutboxDelivery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttemptCount") + .HasColumnType("integer"); + + b.Property("DeadLettered") + .HasColumnType("boolean"); + + b.Property("EventId") + .HasColumnType("uuid"); + + b.Property("HandlerName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("LastError") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Lease") + .HasColumnType("timestamp with time zone"); + + b.Property("Processed") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EventId", "HandlerName") + .IsUnique() + .HasDatabaseName("ix_outbox_deliveries_event_handler"); + + b.HasIndex("HandlerName", "Processed", "DeadLettered") + .HasDatabaseName("ix_outbox_deliveries_handler_processed_dead"); + + b.ToTable("outbox_deliveries", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Outbox.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AggregateDomainVersion") + .HasColumnType("bigint"); + + b.Property("AggregateId") + .HasColumnType("uuid"); + + b.Property("AggregateType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("EventVersion") + .HasColumnType("integer"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("RequestCycleId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OccurredAtUtc") + .HasDatabaseName("ix_outbox_messages_occurredat"); + + b.HasIndex("AggregateId", "EventType", "AggregateDomainVersion") + .IsUnique() + .HasDatabaseName("ix_outbox_messages_aggregate_event_version"); + + b.ToTable("outbox_messages", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Profiles.ProfileExternalLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayLabel") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("profile_external_links", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Profiles.ProfileInterestTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "NormalizedName") + .IsUnique(); + + b.ToTable("profile_interest_tags", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Profiles.RetiredUsername", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedUsername") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("PriorOwnerUserId") + .HasColumnType("uuid"); + + b.Property("RetiredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedUsername") + .IsUnique(); + + b.HasIndex("PriorOwnerUserId"); + + b.ToTable("retired_usernames", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Profiles.UsernameChangeRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CancelledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedRequestedUsername") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RejectionReason") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RequestMonth") + .HasColumnType("integer"); + + b.Property("RequestYear") + .HasColumnType("integer"); + + b.Property("RequestedUsername") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedBy") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "Status"); + + b.HasIndex("UserId", "RequestYear", "RequestMonth"); + + b.ToTable("username_change_requests", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Users.EmailVerificationToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PendingEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("email_verification_tokens", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Users.PasswordResetToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("password_reset_tokens", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Users.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByIp") + .IsRequired() + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserAgent") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarObjectKey") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("AvatarUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("BannerFallbackColor") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("BannerObjectKey") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("BannerUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Elo") + .HasColumnType("integer"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("FailedLoginCount") + .HasColumnType("integer"); + + b.Property("GoogleId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Initials") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("character varying(4)"); + + b.Property("IsEmailVerified") + .HasColumnType("boolean"); + + b.Property("IsSuspended") + .HasColumnType("boolean"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUsernameAdminRequestMonth") + .HasColumnType("integer"); + + b.Property("LastUsernameAdminRequestYear") + .HasColumnType("integer"); + + b.Property("LastUsernameImmediateChangeMonth") + .HasColumnType("integer"); + + b.Property("LastUsernameImmediateChangeYear") + .HasColumnType("integer"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("NormalizedUsername") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProfileType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Player"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.Property("SecurityStamp") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("StatusMessage") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SubscriptionTier") + .IsRequired() + .HasColumnType("text"); + + b.Property("SuspendedUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("Visibility") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Public"); + + b.Property("Xp") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("GoogleId") + .IsUnique() + .HasFilter("\"GoogleId\" IS NOT NULL"); + + b.HasIndex("NormalizedEmail") + .IsUnique(); + + b.HasIndex("NormalizedUsername") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Friends.Block", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("BlockedId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("BlockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Friends.DismissedFriendSuggestion", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("SuggestedUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Friends.Friendship", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("AddresseeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Friends.UserFriendSettings", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Games.GameModeCapability", b => + { + b.HasOne("SimPle.Domain.Games.Game", null) + .WithMany("Capabilities") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Games.GameTag", b => + { + b.HasOne("SimPle.Domain.Games.Game", null) + .WithMany("Tags") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Games.UserFavoriteGame", b => + { + b.HasOne("SimPle.Domain.Games.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Outbox.OutboxDelivery", b => + { + b.HasOne("SimPle.Domain.Outbox.OutboxMessage", null) + .WithMany() + .HasForeignKey("EventId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Profiles.ProfileExternalLink", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Profiles.ProfileInterestTag", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Profiles.UsernameChangeRequest", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Users.EmailVerificationToken", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Users.PasswordResetToken", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Users.RefreshToken", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Games.Game", b => + { + b.Navigation("Capabilities"); + + b.Navigation("Tags"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/SimPle.Infrastructure/Migrations/20260710072312_AddGameCatalog.cs b/src/SimPle.Infrastructure/Migrations/20260710072312_AddGameCatalog.cs new file mode 100644 index 0000000..39ed6d0 --- /dev/null +++ b/src/SimPle.Infrastructure/Migrations/20260710072312_AddGameCatalog.cs @@ -0,0 +1,218 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SimPle.Infrastructure.Migrations +{ + /// + public partial class AddGameCatalog : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "catalog_seed_history", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ManifestVersion = table.Column(type: "text", nullable: false), + Checksum = table.Column(type: "character(64)", fixedLength: true, maxLength: 64, nullable: false), + AppliedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_catalog_seed_history", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "games", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Slug = table.Column(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + Summary = table.Column(type: "text", nullable: false), + RulesSummary = table.Column(type: "text", nullable: false), + Category = table.Column(type: "text", nullable: false), + Difficulty = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + EstimatedDurationMinMinutes = table.Column(type: "integer", nullable: false), + EstimatedDurationMaxMinutes = table.Column(type: "integer", nullable: false), + MinPlayers = table.Column(type: "integer", nullable: false), + MaxPlayers = table.Column(type: "integer", nullable: false), + Lifecycle = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + FeaturedRank = table.Column(type: "integer", nullable: true), + SortOrder = table.Column(type: "integer", nullable: false), + ArtToken = table.Column(type: "text", nullable: false), + ArtColorA = table.Column(type: "text", nullable: false), + ArtColorB = table.Column(type: "text", nullable: false), + ArtAltText = table.Column(type: "text", nullable: false), + ManifestVersion = table.Column(type: "text", nullable: false), + xmin = table.Column(type: "xid", rowVersion: true, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_games", x => x.Id); + table.CheckConstraint("ck_games_draft_retired_not_featured", "(\"Lifecycle\" <> 'Draft' AND \"Lifecycle\" <> 'Retired') OR \"FeaturedRank\" IS NULL"); + table.CheckConstraint("ck_games_duration_bounds", "\"EstimatedDurationMinMinutes\" <= \"EstimatedDurationMaxMinutes\""); + table.CheckConstraint("ck_games_min_players", "\"MinPlayers\" >= 1 AND \"MinPlayers\" <= \"MaxPlayers\""); + }); + + migrationBuilder.CreateTable( + name: "game_mode_capabilities", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + GameId = table.Column(type: "uuid", nullable: false), + Mode = table.Column(type: "text", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_game_mode_capabilities", x => x.Id); + table.ForeignKey( + name: "FK_game_mode_capabilities_games_GameId", + column: x => x.GameId, + principalTable: "games", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "game_tags", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + GameId = table.Column(type: "uuid", nullable: false), + Value = table.Column(type: "text", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_game_tags", x => x.Id); + table.ForeignKey( + name: "FK_game_tags_games_GameId", + column: x => x.GameId, + principalTable: "games", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "user_favorite_games", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + GameId = table.Column(type: "uuid", nullable: false), + IsActive = table.Column(type: "boolean", nullable: false), + CycleId = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_user_favorite_games", x => x.Id); + table.ForeignKey( + name: "FK_user_favorite_games_games_GameId", + column: x => x.GameId, + principalTable: "games", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_user_favorite_games_users_UserId", + column: x => x.UserId, + principalTable: "users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_catalog_seed_history_ManifestVersion", + table: "catalog_seed_history", + column: "ManifestVersion", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_game_mode_capabilities_GameId_Mode", + table: "game_mode_capabilities", + columns: new[] { "GameId", "Mode" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_game_tags_GameId_Value", + table: "game_tags", + columns: new[] { "GameId", "Value" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_games_default_order", + table: "games", + columns: new[] { "FeaturedRank", "SortOrder", "Slug" }); + + migrationBuilder.CreateIndex( + name: "ix_games_difficulty_slug", + table: "games", + columns: new[] { "Difficulty", "Slug" }); + + migrationBuilder.CreateIndex( + name: "ix_games_duration_slug", + table: "games", + columns: new[] { "EstimatedDurationMinMinutes", "Slug" }); + + migrationBuilder.CreateIndex( + name: "ix_games_name_slug", + table: "games", + columns: new[] { "Name", "Slug" }); + + migrationBuilder.CreateIndex( + name: "IX_games_Slug", + table: "games", + column: "Slug", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_user_favorite_games_GameId", + table: "user_favorite_games", + column: "GameId"); + + migrationBuilder.CreateIndex( + name: "IX_user_favorite_games_UserId_GameId", + table: "user_favorite_games", + columns: new[] { "UserId", "GameId" }, + unique: true); + + // Partial unique index: at most one game may have FeaturedRank = 1. EF cannot express a partial + // index declaratively, so this is raw SQL — see GameConfiguration.cs. + migrationBuilder.Sql( + "CREATE UNIQUE INDEX ux_games_featured_rank_one ON games (\"FeaturedRank\") WHERE \"FeaturedRank\" = 1;"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("DROP INDEX IF EXISTS ux_games_featured_rank_one;"); + + migrationBuilder.DropTable( + name: "catalog_seed_history"); + + migrationBuilder.DropTable( + name: "game_mode_capabilities"); + + migrationBuilder.DropTable( + name: "game_tags"); + + migrationBuilder.DropTable( + name: "user_favorite_games"); + + migrationBuilder.DropTable( + name: "games"); + } + } +} diff --git a/src/SimPle.Infrastructure/Migrations/20260710073819_AddGameLifecycleVersion.Designer.cs b/src/SimPle.Infrastructure/Migrations/20260710073819_AddGameLifecycleVersion.Designer.cs new file mode 100644 index 0000000..08a86f5 --- /dev/null +++ b/src/SimPle.Infrastructure/Migrations/20260710073819_AddGameLifecycleVersion.Designer.cs @@ -0,0 +1,1187 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SimPle.Infrastructure.Persistence; + +#nullable disable + +namespace SimPle.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260710073819_AddGameLifecycleVersion")] + partial class AddGameLifecycleVersion + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SimPle.Domain.Friends.Block", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BlockedId") + .HasColumnType("uuid"); + + b.Property("BlockerId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("BlockedId"); + + b.HasIndex("BlockerId"); + + b.HasIndex("BlockerId", "BlockedId") + .IsUnique(); + + b.ToTable("blocks", null, t => + { + t.HasCheckConstraint("ck_no_self_block", "\"BlockerId\" != \"BlockedId\""); + }); + }); + + modelBuilder.Entity("SimPle.Domain.Friends.DismissedFriendSuggestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DismissedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SuggestedUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_dismissed_suggestions_expiresat"); + + b.HasIndex("SuggestedUserId"); + + b.HasIndex("UserId", "SuggestedUserId") + .IsUnique() + .HasDatabaseName("ix_dismissed_suggestions_user_suggested"); + + b.ToTable("dismissed_friend_suggestions", null, t => + { + t.HasCheckConstraint("ck_no_self_dismissal", "\"UserId\" != \"SuggestedUserId\""); + }); + }); + + modelBuilder.Entity("SimPle.Domain.Friends.Friendship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AddresseeId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DomainVersion") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L); + + b.Property("EndReason") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSenderId") + .HasColumnType("uuid"); + + b.Property("NextRequestAllowedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestCycleId") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("SendCountInWindow") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("SendWindowStartUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("TransitionActorId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("AddresseeId", "Status", "SentAt", "Id") + .IsDescending(false, false, true, true) + .HasDatabaseName("ix_friendships_addressee_status_sentat_id"); + + b.HasIndex("RequesterId", "Status", "SentAt", "Id") + .IsDescending(false, false, true, true) + .HasDatabaseName("ix_friendships_requester_status_sentat_id"); + + b.ToTable("friendships", null, t => + { + t.HasCheckConstraint("ck_no_self_friendship", "\"RequesterId\" != \"AddresseeId\""); + }); + }); + + modelBuilder.Entity("SimPle.Domain.Friends.UserFriendSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FriendRequestPrivacy") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("FriendsListVisibility") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("PrivacyPolicyVersion") + .HasColumnType("bigint"); + + b.Property("SearchVisibility") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("user_friend_settings", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Games.CatalogSeedHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppliedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Checksum") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ManifestVersion") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ManifestVersion") + .IsUnique(); + + b.ToTable("catalog_seed_history", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Games.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ArtAltText") + .IsRequired() + .HasColumnType("text"); + + b.Property("ArtColorA") + .IsRequired() + .HasColumnType("text"); + + b.Property("ArtColorB") + .IsRequired() + .HasColumnType("text"); + + b.Property("ArtToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("Category") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Difficulty") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("EstimatedDurationMaxMinutes") + .HasColumnType("integer"); + + b.Property("EstimatedDurationMinMinutes") + .HasColumnType("integer"); + + b.Property("FeaturedRank") + .HasColumnType("integer"); + + b.Property("Lifecycle") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("LifecycleVersion") + .HasColumnType("integer"); + + b.Property("ManifestVersion") + .IsRequired() + .HasColumnType("text"); + + b.Property("MaxPlayers") + .HasColumnType("integer"); + + b.Property("MinPlayers") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("RulesSummary") + .IsRequired() + .HasColumnType("text"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("Difficulty", "Slug") + .HasDatabaseName("ix_games_difficulty_slug"); + + b.HasIndex("EstimatedDurationMinMinutes", "Slug") + .HasDatabaseName("ix_games_duration_slug"); + + b.HasIndex("Name", "Slug") + .HasDatabaseName("ix_games_name_slug"); + + b.HasIndex("FeaturedRank", "SortOrder", "Slug") + .HasDatabaseName("ix_games_default_order"); + + b.ToTable("games", null, t => + { + t.HasCheckConstraint("ck_games_draft_retired_not_featured", "(\"Lifecycle\" <> 'Draft' AND \"Lifecycle\" <> 'Retired') OR \"FeaturedRank\" IS NULL"); + + t.HasCheckConstraint("ck_games_duration_bounds", "\"EstimatedDurationMinMinutes\" <= \"EstimatedDurationMaxMinutes\""); + + t.HasCheckConstraint("ck_games_min_players", "\"MinPlayers\" >= 1 AND \"MinPlayers\" <= \"MaxPlayers\""); + }); + }); + + modelBuilder.Entity("SimPle.Domain.Games.GameModeCapability", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GameId") + .HasColumnType("uuid"); + + b.Property("Mode") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("GameId", "Mode") + .IsUnique(); + + b.ToTable("game_mode_capabilities", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Games.GameTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GameId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GameId", "Value") + .IsUnique(); + + b.ToTable("game_tags", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Games.UserFavoriteGame", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CycleId") + .HasColumnType("integer"); + + b.Property("GameId") + .HasColumnType("uuid"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("UserId", "GameId") + .IsUnique(); + + b.ToTable("user_favorite_games", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Outbox.OutboxDelivery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttemptCount") + .HasColumnType("integer"); + + b.Property("DeadLettered") + .HasColumnType("boolean"); + + b.Property("EventId") + .HasColumnType("uuid"); + + b.Property("HandlerName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("LastError") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Lease") + .HasColumnType("timestamp with time zone"); + + b.Property("Processed") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EventId", "HandlerName") + .IsUnique() + .HasDatabaseName("ix_outbox_deliveries_event_handler"); + + b.HasIndex("HandlerName", "Processed", "DeadLettered") + .HasDatabaseName("ix_outbox_deliveries_handler_processed_dead"); + + b.ToTable("outbox_deliveries", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Outbox.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AggregateDomainVersion") + .HasColumnType("bigint"); + + b.Property("AggregateId") + .HasColumnType("uuid"); + + b.Property("AggregateType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("EventVersion") + .HasColumnType("integer"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("RequestCycleId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OccurredAtUtc") + .HasDatabaseName("ix_outbox_messages_occurredat"); + + b.HasIndex("AggregateId", "EventType", "AggregateDomainVersion") + .IsUnique() + .HasDatabaseName("ix_outbox_messages_aggregate_event_version"); + + b.ToTable("outbox_messages", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Profiles.ProfileExternalLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayLabel") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("profile_external_links", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Profiles.ProfileInterestTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "NormalizedName") + .IsUnique(); + + b.ToTable("profile_interest_tags", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Profiles.RetiredUsername", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedUsername") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("PriorOwnerUserId") + .HasColumnType("uuid"); + + b.Property("RetiredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedUsername") + .IsUnique(); + + b.HasIndex("PriorOwnerUserId"); + + b.ToTable("retired_usernames", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Profiles.UsernameChangeRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CancelledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedRequestedUsername") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RejectionReason") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RequestMonth") + .HasColumnType("integer"); + + b.Property("RequestYear") + .HasColumnType("integer"); + + b.Property("RequestedUsername") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedBy") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "Status"); + + b.HasIndex("UserId", "RequestYear", "RequestMonth"); + + b.ToTable("username_change_requests", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Users.EmailVerificationToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PendingEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("email_verification_tokens", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Users.PasswordResetToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("password_reset_tokens", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Users.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByIp") + .IsRequired() + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserAgent") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarObjectKey") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("AvatarUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("BannerFallbackColor") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("BannerObjectKey") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("BannerUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Elo") + .HasColumnType("integer"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("FailedLoginCount") + .HasColumnType("integer"); + + b.Property("GoogleId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Initials") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("character varying(4)"); + + b.Property("IsEmailVerified") + .HasColumnType("boolean"); + + b.Property("IsSuspended") + .HasColumnType("boolean"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUsernameAdminRequestMonth") + .HasColumnType("integer"); + + b.Property("LastUsernameAdminRequestYear") + .HasColumnType("integer"); + + b.Property("LastUsernameImmediateChangeMonth") + .HasColumnType("integer"); + + b.Property("LastUsernameImmediateChangeYear") + .HasColumnType("integer"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("NormalizedUsername") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProfileType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Player"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.Property("SecurityStamp") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("StatusMessage") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SubscriptionTier") + .IsRequired() + .HasColumnType("text"); + + b.Property("SuspendedUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("Visibility") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Public"); + + b.Property("Xp") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("GoogleId") + .IsUnique() + .HasFilter("\"GoogleId\" IS NOT NULL"); + + b.HasIndex("NormalizedEmail") + .IsUnique(); + + b.HasIndex("NormalizedUsername") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Friends.Block", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("BlockedId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("BlockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Friends.DismissedFriendSuggestion", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("SuggestedUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Friends.Friendship", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("AddresseeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Friends.UserFriendSettings", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Games.GameModeCapability", b => + { + b.HasOne("SimPle.Domain.Games.Game", null) + .WithMany("Capabilities") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Games.GameTag", b => + { + b.HasOne("SimPle.Domain.Games.Game", null) + .WithMany("Tags") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Games.UserFavoriteGame", b => + { + b.HasOne("SimPle.Domain.Games.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Outbox.OutboxDelivery", b => + { + b.HasOne("SimPle.Domain.Outbox.OutboxMessage", null) + .WithMany() + .HasForeignKey("EventId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Profiles.ProfileExternalLink", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Profiles.ProfileInterestTag", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Profiles.UsernameChangeRequest", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Users.EmailVerificationToken", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Users.PasswordResetToken", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Users.RefreshToken", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Games.Game", b => + { + b.Navigation("Capabilities"); + + b.Navigation("Tags"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/SimPle.Infrastructure/Migrations/20260710073819_AddGameLifecycleVersion.cs b/src/SimPle.Infrastructure/Migrations/20260710073819_AddGameLifecycleVersion.cs new file mode 100644 index 0000000..ae66511 --- /dev/null +++ b/src/SimPle.Infrastructure/Migrations/20260710073819_AddGameLifecycleVersion.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SimPle.Infrastructure.Migrations +{ + /// + public partial class AddGameLifecycleVersion : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "LifecycleVersion", + table: "games", + type: "integer", + nullable: false, + defaultValue: 1); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "LifecycleVersion", + table: "games"); + } + } +} diff --git a/src/SimPle.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/src/SimPle.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 9cbf2be..d6cfdd3 100644 --- a/src/SimPle.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/src/SimPle.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -222,6 +222,243 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("user_friend_settings", (string)null); }); + modelBuilder.Entity("SimPle.Domain.Games.CatalogSeedHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppliedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Checksum") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ManifestVersion") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ManifestVersion") + .IsUnique(); + + b.ToTable("catalog_seed_history", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Games.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ArtAltText") + .IsRequired() + .HasColumnType("text"); + + b.Property("ArtColorA") + .IsRequired() + .HasColumnType("text"); + + b.Property("ArtColorB") + .IsRequired() + .HasColumnType("text"); + + b.Property("ArtToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("Category") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Difficulty") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("EstimatedDurationMaxMinutes") + .HasColumnType("integer"); + + b.Property("EstimatedDurationMinMinutes") + .HasColumnType("integer"); + + b.Property("FeaturedRank") + .HasColumnType("integer"); + + b.Property("Lifecycle") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("LifecycleVersion") + .HasColumnType("integer"); + + b.Property("ManifestVersion") + .IsRequired() + .HasColumnType("text"); + + b.Property("MaxPlayers") + .HasColumnType("integer"); + + b.Property("MinPlayers") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("RulesSummary") + .IsRequired() + .HasColumnType("text"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("Difficulty", "Slug") + .HasDatabaseName("ix_games_difficulty_slug"); + + b.HasIndex("EstimatedDurationMinMinutes", "Slug") + .HasDatabaseName("ix_games_duration_slug"); + + b.HasIndex("Name", "Slug") + .HasDatabaseName("ix_games_name_slug"); + + b.HasIndex("FeaturedRank", "SortOrder", "Slug") + .HasDatabaseName("ix_games_default_order"); + + b.ToTable("games", null, t => + { + t.HasCheckConstraint("ck_games_draft_retired_not_featured", "(\"Lifecycle\" <> 'Draft' AND \"Lifecycle\" <> 'Retired') OR \"FeaturedRank\" IS NULL"); + + t.HasCheckConstraint("ck_games_duration_bounds", "\"EstimatedDurationMinMinutes\" <= \"EstimatedDurationMaxMinutes\""); + + t.HasCheckConstraint("ck_games_min_players", "\"MinPlayers\" >= 1 AND \"MinPlayers\" <= \"MaxPlayers\""); + }); + }); + + modelBuilder.Entity("SimPle.Domain.Games.GameModeCapability", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GameId") + .HasColumnType("uuid"); + + b.Property("Mode") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("GameId", "Mode") + .IsUnique(); + + b.ToTable("game_mode_capabilities", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Games.GameTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GameId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GameId", "Value") + .IsUnique(); + + b.ToTable("game_tags", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Games.UserFavoriteGame", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CycleId") + .HasColumnType("integer"); + + b.Property("GameId") + .HasColumnType("uuid"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("UserId", "GameId") + .IsUnique(); + + b.ToTable("user_favorite_games", (string)null); + }); + modelBuilder.Entity("SimPle.Domain.Outbox.OutboxDelivery", b => { b.Property("Id") @@ -839,6 +1076,39 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("SimPle.Domain.Games.GameModeCapability", b => + { + b.HasOne("SimPle.Domain.Games.Game", null) + .WithMany("Capabilities") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Games.GameTag", b => + { + b.HasOne("SimPle.Domain.Games.Game", null) + .WithMany("Tags") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Games.UserFavoriteGame", b => + { + b.HasOne("SimPle.Domain.Games.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("SimPle.Domain.Outbox.OutboxDelivery", b => { b.HasOne("SimPle.Domain.Outbox.OutboxMessage", null) @@ -901,6 +1171,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); + + modelBuilder.Entity("SimPle.Domain.Games.Game", b => + { + b.Navigation("Capabilities"); + + b.Navigation("Tags"); + }); #pragma warning restore 612, 618 } } diff --git a/src/SimPle.Infrastructure/Persistence/AppDbContext.cs b/src/SimPle.Infrastructure/Persistence/AppDbContext.cs index 55b4795..8d82167 100644 --- a/src/SimPle.Infrastructure/Persistence/AppDbContext.cs +++ b/src/SimPle.Infrastructure/Persistence/AppDbContext.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using SimPle.Domain.Friends; +using SimPle.Domain.Games; using SimPle.Domain.Outbox; using SimPle.Domain.Profiles; using SimPle.Domain.Users; @@ -31,6 +32,13 @@ public AppDbContext(DbContextOptions options) : base(options) { } public DbSet OutboxMessages => Set(); public DbSet OutboxDeliveries => Set(); + // Module 4 — game library & discovery + public DbSet Games => Set(); + public DbSet GameTags => Set(); + public DbSet GameModeCapabilities => Set(); + public DbSet UserFavoriteGames => Set(); + public DbSet CatalogSeedHistory => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); diff --git a/src/SimPle.Infrastructure/Persistence/Configurations/CatalogSeedHistoryConfiguration.cs b/src/SimPle.Infrastructure/Persistence/Configurations/CatalogSeedHistoryConfiguration.cs new file mode 100644 index 0000000..e50021f --- /dev/null +++ b/src/SimPle.Infrastructure/Persistence/Configurations/CatalogSeedHistoryConfiguration.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SimPle.Domain.Games; + +namespace SimPle.Infrastructure.Persistence.Configurations; + +public sealed class CatalogSeedHistoryConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("catalog_seed_history"); + + builder.HasKey(h => h.Id); + builder.Property(h => h.ManifestVersion).IsRequired(); + builder.Property(h => h.Checksum).HasMaxLength(64).IsFixedLength().IsRequired(); + builder.Property(h => h.AppliedAtUtc).IsRequired(); + + builder.HasIndex(h => h.ManifestVersion).IsUnique(); + } +} diff --git a/src/SimPle.Infrastructure/Persistence/Configurations/GameConfiguration.cs b/src/SimPle.Infrastructure/Persistence/Configurations/GameConfiguration.cs new file mode 100644 index 0000000..eb32692 --- /dev/null +++ b/src/SimPle.Infrastructure/Persistence/Configurations/GameConfiguration.cs @@ -0,0 +1,64 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SimPle.Domain.Games; + +namespace SimPle.Infrastructure.Persistence.Configurations; + +public sealed class GameConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("games", t => + { + t.HasCheckConstraint("ck_games_min_players", "\"MinPlayers\" >= 1 AND \"MinPlayers\" <= \"MaxPlayers\""); + t.HasCheckConstraint("ck_games_duration_bounds", "\"EstimatedDurationMinMinutes\" <= \"EstimatedDurationMaxMinutes\""); + t.HasCheckConstraint("ck_games_draft_retired_not_featured", + "(\"Lifecycle\" <> 'Draft' AND \"Lifecycle\" <> 'Retired') OR \"FeaturedRank\" IS NULL"); + }); + + builder.HasKey(g => g.Id); + + builder.Property(g => g.Slug).IsRequired(); + builder.Property(g => g.Name).IsRequired(); + builder.Property(g => g.Summary).IsRequired(); + builder.Property(g => g.RulesSummary).IsRequired(); + builder.Property(g => g.Category).IsRequired(); + builder.Property(g => g.Difficulty).HasConversion().HasMaxLength(16).IsRequired(); + builder.Property(g => g.Lifecycle).HasConversion().HasMaxLength(16).IsRequired(); + builder.Property(g => g.LifecycleVersion).IsRequired(); + builder.Property(g => g.ArtToken).IsRequired(); + builder.Property(g => g.ArtColorA).IsRequired(); + builder.Property(g => g.ArtColorB).IsRequired(); + builder.Property(g => g.ArtAltText).IsRequired(); + builder.Property(g => g.ManifestVersion).IsRequired(); + + // Npgsql row-version pattern: uint property mapped to xmin (optimistic concurrency token). + builder.Property(g => g.Version).IsRowVersion(); + + builder.HasIndex(g => g.Slug).IsUnique(); + + // Default order: (FeaturedRank NULLS LAST, SortOrder, Slug) — plain ascending index; Postgres's + // default NULLS LAST for ASC already matches the intent, no raw SQL needed. + builder.HasIndex(g => new { g.FeaturedRank, g.SortOrder, g.Slug }).HasDatabaseName("ix_games_default_order"); + + // Alternate-sort indexes. + builder.HasIndex(g => new { g.Name, g.Slug }).HasDatabaseName("ix_games_name_slug"); + builder.HasIndex(g => new { g.Difficulty, g.Slug }).HasDatabaseName("ix_games_difficulty_slug"); + builder.HasIndex(g => new { g.EstimatedDurationMinMinutes, g.Slug }).HasDatabaseName("ix_games_duration_slug"); + + builder.HasMany(g => g.Tags) + .WithOne() + .HasForeignKey(t => t.GameId) + .OnDelete(DeleteBehavior.Cascade); + builder.Navigation(g => g.Tags).UsePropertyAccessMode(PropertyAccessMode.Field); + + builder.HasMany(g => g.Capabilities) + .WithOne() + .HasForeignKey(c => c.GameId) + .OnDelete(DeleteBehavior.Cascade); + builder.Navigation(g => g.Capabilities).UsePropertyAccessMode(PropertyAccessMode.Field); + + // NOTE: ux_games_featured_rank_one (partial unique index on FeaturedRank = 1) cannot be generated by + // EF; it is added as raw SQL in the AddGameCatalog migration. + } +} diff --git a/src/SimPle.Infrastructure/Persistence/Configurations/GameModeCapabilityConfiguration.cs b/src/SimPle.Infrastructure/Persistence/Configurations/GameModeCapabilityConfiguration.cs new file mode 100644 index 0000000..b86cfaa --- /dev/null +++ b/src/SimPle.Infrastructure/Persistence/Configurations/GameModeCapabilityConfiguration.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SimPle.Domain.Games; + +namespace SimPle.Infrastructure.Persistence.Configurations; + +public sealed class GameModeCapabilityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("game_mode_capabilities"); + + builder.HasKey(c => c.Id); + builder.Property(c => c.Mode).IsRequired(); + + builder.HasIndex(c => new { c.GameId, c.Mode }).IsUnique(); + } +} diff --git a/src/SimPle.Infrastructure/Persistence/Configurations/GameTagConfiguration.cs b/src/SimPle.Infrastructure/Persistence/Configurations/GameTagConfiguration.cs new file mode 100644 index 0000000..cb1a00f --- /dev/null +++ b/src/SimPle.Infrastructure/Persistence/Configurations/GameTagConfiguration.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SimPle.Domain.Games; + +namespace SimPle.Infrastructure.Persistence.Configurations; + +public sealed class GameTagConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("game_tags"); + + builder.HasKey(t => t.Id); + builder.Property(t => t.Value).IsRequired(); + + builder.HasIndex(t => new { t.GameId, t.Value }).IsUnique(); + } +} diff --git a/src/SimPle.Infrastructure/Persistence/Configurations/UserFavoriteGameConfiguration.cs b/src/SimPle.Infrastructure/Persistence/Configurations/UserFavoriteGameConfiguration.cs new file mode 100644 index 0000000..fd1ca64 --- /dev/null +++ b/src/SimPle.Infrastructure/Persistence/Configurations/UserFavoriteGameConfiguration.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SimPle.Domain.Games; +using SimPle.Domain.Users; + +namespace SimPle.Infrastructure.Persistence.Configurations; + +public sealed class UserFavoriteGameConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("user_favorite_games"); + + builder.HasKey(f => f.Id); + builder.Property(f => f.IsActive).IsRequired(); + builder.Property(f => f.CycleId).IsRequired(); + + builder.HasIndex(f => new { f.UserId, f.GameId }).IsUnique(); + + builder.HasOne().WithMany().HasForeignKey(f => f.UserId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne().WithMany().HasForeignKey(f => f.GameId).OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/src/SimPle.Infrastructure/SimPle.Infrastructure.csproj b/src/SimPle.Infrastructure/SimPle.Infrastructure.csproj index 89ac2b8..9f9d8f9 100644 --- a/src/SimPle.Infrastructure/SimPle.Infrastructure.csproj +++ b/src/SimPle.Infrastructure/SimPle.Infrastructure.csproj @@ -6,6 +6,11 @@ + + + + + From 2e30c2cc4e7a2d731fcce850af2cdf9a07cd0bcd Mon Sep 17 00:00:00 2001 From: Mohannad Ehab <157999295+MohanEhab@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:21:15 +0300 Subject: [PATCH 2/3] feat(module-04-games): add games API, favorites, and rate limiting Wires GamesController (list, detail, featured, favorites) through GamesService/GameRepository with cache/ETag/304 handling, cursor pagination, an outbox for favorite/lifecycle events, and three dedicated rate-limiter policies for catalog reads, search, and favorites. --- src/SimPle.Api/Controllers/GamesController.cs | 210 ++++++++++++ src/SimPle.Api/Program.cs | 57 +++- .../Common/Interfaces/IGameRepository.cs | 61 ++++ .../Common/Pagination/Cursor.cs | 23 ++ src/SimPle.Application/DependencyInjection.cs | 2 + .../Games/DTOs/GameCatalogDto.cs | 28 ++ .../Games/DTOs/GameEntryActionDto.cs | 8 + .../Games/DTOs/GameFavoriteDto.cs | 14 + .../Games/DTOs/GameTombstoneDto.cs | 7 + .../Games/FavoriteOutcomes.cs | 5 + .../Games/GameCatalogSortKey.cs | 61 ++++ .../Games/GameEntryActions.cs | 20 ++ .../Games/Outbox/GameOutbox.cs | 52 +++ .../Games/Services/GamesService.cs | 317 ++++++++++++++++++ .../Games/Services/IGamesService.cs | 41 +++ .../Repositories/GameRepository.cs | 202 +++++++++++ 16 files changed, 1097 insertions(+), 11 deletions(-) create mode 100644 src/SimPle.Api/Controllers/GamesController.cs create mode 100644 src/SimPle.Application/Common/Interfaces/IGameRepository.cs create mode 100644 src/SimPle.Application/Games/DTOs/GameCatalogDto.cs create mode 100644 src/SimPle.Application/Games/DTOs/GameEntryActionDto.cs create mode 100644 src/SimPle.Application/Games/DTOs/GameFavoriteDto.cs create mode 100644 src/SimPle.Application/Games/DTOs/GameTombstoneDto.cs create mode 100644 src/SimPle.Application/Games/FavoriteOutcomes.cs create mode 100644 src/SimPle.Application/Games/GameCatalogSortKey.cs create mode 100644 src/SimPle.Application/Games/GameEntryActions.cs create mode 100644 src/SimPle.Application/Games/Outbox/GameOutbox.cs create mode 100644 src/SimPle.Application/Games/Services/GamesService.cs create mode 100644 src/SimPle.Application/Games/Services/IGamesService.cs create mode 100644 src/SimPle.Infrastructure/Persistence/Repositories/GameRepository.cs diff --git a/src/SimPle.Api/Controllers/GamesController.cs b/src/SimPle.Api/Controllers/GamesController.cs new file mode 100644 index 0000000..d9868eb --- /dev/null +++ b/src/SimPle.Api/Controllers/GamesController.cs @@ -0,0 +1,210 @@ +using System.IdentityModel.Tokens.Jwt; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using SimPle.Api.Models; +using SimPle.Application.Games.DTOs; +using SimPle.Application.Games.Services; +using SimPle.Shared.Common; +using Swashbuckle.AspNetCore.Annotations; + +namespace SimPle.Api.Controllers; + +/// +/// Public catalog reads (list/detail/featured) are anonymous, auth-independent, and cache-headered per spec +/// deviation D1 (Vary: Cookie, not Vary: Authorization — this app authenticates via cookie only, +/// see ProfileController.cs:112-115). Favorites are authenticated, ownership-scoped from the JWT +/// sub claim only (never a request body id), and never cached. +/// +[ApiController] +[Route("api/games")] +[Produces("application/json")] +[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status500InternalServerError)] +public sealed class GamesController : ControllerBase +{ + private readonly IGamesService _games; + + public GamesController(IGamesService games) + { + _games = games; + } + + // ── Public catalog reads ──────────────────────────────────────────────── + + [HttpGet] + [EnableRateLimiting("catalog-read")] + [SwaggerOperation(Summary = "List/search the game catalog (keyset cursor paged)", + OperationId = "Games_List", Tags = new[] { "Games" })] + [ProducesResponseType(typeof(CursorPage), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status304NotModified)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)] + public async Task List( + [FromQuery] string? query, + [FromQuery] string[]? category, + [FromQuery] string[]? tag, + [FromQuery] string[]? mode, + [FromQuery] string[]? lifecycle, + [FromQuery] string? sort, + [FromQuery] int limit = 24, + [FromQuery] string? after = null, + CancellationToken ct = default) + { + var result = await _games.ListAsync(query, category, tag, mode, lifecycle, sort, limit, after, ct); + if (!result.IsSuccess) return MapError(result.Error!); + + var (page, etag) = result.Value!; + return IfNoneMatchMatches(etag) ? NotModified(etag) : CachedOk(page, etag); + } + + [HttpGet("featured")] + [EnableRateLimiting("catalog-read")] + [SwaggerOperation(Summary = "Get the single featured game, or none", + OperationId = "Games_GetFeatured", Tags = new[] { "Games" })] + [ProducesResponseType(typeof(GameCatalogDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status304NotModified)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)] + public async Task GetFeatured(CancellationToken ct) + { + var result = await _games.GetFeaturedAsync(ct); + var featured = result.Value!; + if (featured.Game is null) return NoContent(); + + return IfNoneMatchMatches(featured.ETag!) ? NotModified(featured.ETag!) : CachedOk(featured.Game, featured.ETag!); + } + + [HttpGet("{slug}")] + [EnableRateLimiting("catalog-read")] + [SwaggerOperation(Summary = "Get a single game by slug", + OperationId = "Games_GetBySlug", Tags = new[] { "Games" })] + [ProducesResponseType(typeof(GameCatalogDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status304NotModified)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(GameTombstoneDto), StatusCodes.Status410Gone)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)] + public async Task GetBySlug([FromRoute] string slug, CancellationToken ct) + { + var result = await _games.GetDetailAsync(slug, ct); + if (!result.IsSuccess) return MapError(result.Error!); + + var detail = result.Value!; + // 410 carries the minimal tombstone DTO directly (not the ApiErrorResponse envelope) per the spec's + // DTOs section; cache headers apply only to 200/304, never to 410. + if (detail.Tombstone is not null) return StatusCode(StatusCodes.Status410Gone, detail.Tombstone); + + return IfNoneMatchMatches(detail.ETag!) ? NotModified(detail.ETag!) : CachedOk(detail.Game, detail.ETag!); + } + + // ── Authenticated favorites ───────────────────────────────────────────── + + [HttpGet("me/favorites")] + [Authorize] + [EnableRateLimiting("game-favorites")] + [SwaggerOperation(Summary = "List the authenticated user's active favorites (keyset cursor paged)", + OperationId = "Games_GetFavorites", Tags = new[] { "Games" })] + [ProducesResponseType(typeof(CursorPage), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)] + public async Task GetFavorites( + [FromQuery] int limit = 24, [FromQuery] string? after = null, CancellationToken ct = default) + { + if (!TryGetUserId(out var userId)) return Unauthorized(); + + Response.Headers.CacheControl = "private, no-store"; + var result = await _games.GetFavoritesAsync(userId, limit, after, ct); + return result.IsSuccess ? Ok(result.Value) : MapError(result.Error!); + } + + [HttpPut("me/favorites/{slug}")] + [Authorize] + [EnableRateLimiting("game-favorites")] + [SwaggerOperation(Summary = "Favorite a game (idempotent — identical DTO on repeat calls)", + OperationId = "Games_PutFavorite", Tags = new[] { "Games" })] + [ProducesResponseType(typeof(GameFavoriteDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)] + public async Task PutFavorite([FromRoute] string slug, CancellationToken ct) + { + if (!HasCsrfHeader()) return MissingCsrfHeader(); + if (!TryGetUserId(out var userId)) return Unauthorized(); + + Response.Headers.CacheControl = "private, no-store"; + var result = await _games.PutFavoriteAsync(userId, slug, ct); + return result.IsSuccess ? Ok(result.Value) : MapError(result.Error!); + } + + [HttpDelete("me/favorites/{slug}")] + [Authorize] + [EnableRateLimiting("game-favorites")] + [SwaggerOperation(Summary = "Unfavorite a game (idempotent — 204 whether present, absent, or already inactive)", + OperationId = "Games_DeleteFavorite", Tags = new[] { "Games" })] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)] + public async Task DeleteFavorite([FromRoute] string slug, CancellationToken ct) + { + if (!HasCsrfHeader()) return MissingCsrfHeader(); + if (!TryGetUserId(out var userId)) return Unauthorized(); + + Response.Headers.CacheControl = "private, no-store"; + var result = await _games.DeleteFavoriteAsync(userId, slug, ct); + return result.IsSuccess ? NoContent() : MapError(result.Error!); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private const string CsrfHeader = "X-Requested-With"; + private const string CsrfHeaderValue = "XMLHttpRequest"; + + private bool HasCsrfHeader() => + string.Equals(Request.Headers[CsrfHeader], CsrfHeaderValue, StringComparison.Ordinal); + + private IActionResult MissingCsrfHeader() => BadRequest(Error( + "Auth.CsrfHeaderRequired", + $"The {CsrfHeader} header is required for this request.")); + + private bool TryGetUserId(out Guid userId) => + Guid.TryParse(User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value, out userId); + + private bool IfNoneMatchMatches(string etag) + { + var header = Request.Headers.IfNoneMatch.ToString(); + if (string.IsNullOrEmpty(header)) return false; + return header.Split(',').Select(v => v.Trim()).Any(v => v == etag || v == "*"); + } + + private IActionResult NotModified(string etag) + { + Response.Headers.ETag = etag; + Response.Headers.CacheControl = "public, max-age=60"; + Response.Headers.Vary = "Cookie"; + return StatusCode(StatusCodes.Status304NotModified); + } + + private IActionResult CachedOk(T value, string etag) + { + Response.Headers.ETag = etag; + Response.Headers.CacheControl = "public, max-age=60"; + Response.Headers.Vary = "Cookie"; + return Ok(value); + } + + /// Games.NotFound -> 404, Games.Retired -> 409 (new-favorite rejection only; the detail-read + /// 410 tombstone path never reaches this switch), everything else (Validation.Failed, + /// Pagination.InvalidCursor) -> 400. + private IActionResult MapError(Error error) => + error.Code switch + { + "Games.NotFound" => NotFound(Error(error.Code, error.Message)), + "Games.Retired" => Conflict(Error(error.Code, error.Message)), + _ => BadRequest(Error(error.Code, error.Message)), + }; + + private static ApiErrorResponse Error(string code, string message) => + new(new ApiErrorDetail(code, message)); +} diff --git a/src/SimPle.Api/Program.cs b/src/SimPle.Api/Program.cs index 1206aa6..eb2aaf1 100644 --- a/src/SimPle.Api/Program.cs +++ b/src/SimPle.Api/Program.cs @@ -20,6 +20,8 @@ using SimPle.Application.Common.Options; using SimPle.Infrastructure; using SimPle.Infrastructure.Auth; +using SimPle.Infrastructure.Games; +using SimPle.Infrastructure.Persistence; var builder = WebApplication.CreateBuilder(args); @@ -271,22 +273,44 @@ await context.HttpContext.Response.WriteAsJsonAsync(new ApiErrorResponse( options.AddPolicy("profile-friends", context => FriendWindow(context, "pfrd", 120, TimeSpan.FromMinutes(1))); options.AddPolicy("profile-mutual-friends", context => FriendWindow(context, "pmut", 120, TimeSpan.FromMinutes(1))); - // Chained per-IP ceiling for discovery/people-search (spec: 30/min/account + 120/hour/IP). Scoped by - // request path so it only "bites" on these two routes; every other route gets a permanent no-op lease. + // Game catalog policies (per IP for anonymous public reads; per account, IP-fallback for favorites). + options.AddPolicy("catalog-read", context => AuthWindow(context, 120, TimeSpan.FromMinutes(1))); + options.AddPolicy("game-favorites", context => FriendWindow(context, "gfav", 60, TimeSpan.FromMinutes(1))); + + // Chained per-IP ceiling for discovery/people-search (spec: 30/min/account + 120/hour/IP), and for + // catalog search (spec: 120/min/IP catalog-read + 30/min/IP catalog-search when `query` is present). + // Scoped by request path/query so it only "bites" on these routes; every other route gets a permanent + // no-op lease. options.GlobalLimiter = PartitionedRateLimiter.Create(context => { var path = context.Request.Path; - if (!path.StartsWithSegments("/api/people/search") && !path.StartsWithSegments("/api/friends/discovery")) - return RateLimitPartition.GetNoLimiter("no-limit"); - var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; - return RateLimitPartition.GetFixedWindowLimiter($"people-ip:{ip}", _ => new FixedWindowRateLimiterOptions + + if (path.StartsWithSegments("/api/people/search") || path.StartsWithSegments("/api/friends/discovery")) { - PermitLimit = 120, - Window = TimeSpan.FromHours(1), - QueueLimit = 0, - QueueProcessingOrder = QueueProcessingOrder.OldestFirst - }); + return RateLimitPartition.GetFixedWindowLimiter($"people-ip:{ip}", _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 120, + Window = TimeSpan.FromHours(1), + QueueLimit = 0, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst + }); + } + + if (HttpMethods.IsGet(context.Request.Method) && + path.StartsWithSegments("/api/games", out var remaining) && remaining == PathString.Empty && + !string.IsNullOrEmpty(context.Request.Query["query"])) + { + return RateLimitPartition.GetFixedWindowLimiter($"catalog-search-ip:{ip}", _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 30, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst + }); + } + + return RateLimitPartition.GetNoLimiter("no-limit"); }); }); @@ -348,6 +372,17 @@ static RateLimitPartition FriendWindow(HttpContext context, string prefi }); } +if (args.Contains("--seed-game-catalog")) +{ + using var scope = app.Services.CreateScope(); + var seedDb = scope.ServiceProvider.GetRequiredService(); + var seedLogger = scope.ServiceProvider.GetRequiredService().CreateLogger(); + var seeder = new GameCatalogSeeder(seedDb, seedLogger); + var seedResult = seeder.SeedAsync().GetAwaiter().GetResult(); + Console.WriteLine(seedResult.Message); + Environment.Exit(seedResult.Success ? 0 : 1); +} + app.Run(); public partial class Program { } diff --git a/src/SimPle.Application/Common/Interfaces/IGameRepository.cs b/src/SimPle.Application/Common/Interfaces/IGameRepository.cs new file mode 100644 index 0000000..d66ae79 --- /dev/null +++ b/src/SimPle.Application/Common/Interfaces/IGameRepository.cs @@ -0,0 +1,61 @@ +using SimPle.Application.Games; +using SimPle.Domain.Games; +using SimPle.Domain.Outbox; + +namespace SimPle.Application.Common.Interfaces; + +/// +/// Fully normalized/validated catalog query, built by +/// and consumed by GameRepository. / are the decoded +/// keyset position (null on the first page); their string shape is defined by . +/// +public sealed record GameCatalogFilter( + string? NormalizedSearch, + IReadOnlyList Categories, + IReadOnlyList Tags, + IReadOnlyList Modes, + IReadOnlyList Lifecycles, + string Sort, + int Limit, + string? AfterSortKey, + string? AfterSlug); + +/// +/// Data access for the game catalog and favorites. List/detail/featured reads are budgeted at exactly two SQL +/// round trips: // +/// fetch the game row(s) only, and fetches tags and mode +/// capabilities for those ids in a single hand-written UNION ALL query (EF's AsSplitQuery would cost a +/// third round trip, which the spec's performance budget forbids). +/// +public interface IGameRepository +{ + Task> GetCatalogPageAsync(GameCatalogFilter filter, CancellationToken ct = default); + + /// Flat (GameId, Kind, Value) rows for the given ids, Kind is "tag" or "mode". + Task> GetTagsAndCapabilitiesAsync( + IReadOnlyList gameIds, CancellationToken ct = default); + + /// Any lifecycle, including Draft/Retired — the service decides visibility (404 vs 410). + Task GetBySlugAsync(string slug, CancellationToken ct = default); + + /// The single FeaturedRank = 1 game, or null when none exists (204 case). + Task GetFeaturedAsync(CancellationToken ct = default); + + // ── Favorites ──────────────────────────────────────────────────────────── + + /// Active favorites ordered by (UpdatedAt DESC, Id DESC). + Task> GetFavoritesPageAsync( + Guid userId, int limit, DateTime? afterFavoritedAt, Guid? afterId, CancellationToken ct = default); + + Task GetFavoriteAsync(Guid userId, Guid gameId, CancellationToken ct = default); + + /// Brand-new (UserId, GameId) row. Conflict = a concurrent first favorite already won the race. + Task AddFavoriteAsync(UserFavoriteGame favorite, OutboxMessage evt, CancellationToken ct = default); + + /// + /// Toggles an existing row (Refavorite/Unfavorite already called by the caller). ConcurrencyConflict means + /// the outbox's unique (AggregateId, EventType, AggregateDomainVersion) index rejected a retried transition + /// or a racing writer already applied it — the caller re-reads and converges. + /// + Task UpdateFavoriteAsync(UserFavoriteGame favorite, OutboxMessage evt, CancellationToken ct = default); +} diff --git a/src/SimPle.Application/Common/Pagination/Cursor.cs b/src/SimPle.Application/Common/Pagination/Cursor.cs index 22aa860..3b06494 100644 --- a/src/SimPle.Application/Common/Pagination/Cursor.cs +++ b/src/SimPle.Application/Common/Pagination/Cursor.cs @@ -107,6 +107,29 @@ public static bool TryDecodeProfileList( return true; } + // ── Game catalog cursor: (sortKey, Slug) position, bound to a hash of the normalized query shape + // (search text, category/tag/mode/lifecycle filters, sort) so a cursor cannot be replayed after the + // filter/sort shape changes — it must fail with Pagination.InvalidCursor rather than blend result sets. + // sortKey is a caller-formatted, order-preserving string representation of the active sort's key(s) + // (e.g. the default order's zero-padded FeaturedRank/SortOrder composite, or the uppercased Name). + + public static string EncodeCatalog(string sortKey, string slug, string queryShapeHash) => + ToBase64Url($"{ToBase64Url(sortKey)}{Sep}{ToBase64Url(slug)}{Sep}{ToBase64Url(queryShapeHash)}"); + + public static bool TryDecodeCatalog(string? cursor, out string sortKey, out string slug, out string queryShapeHash) + { + sortKey = string.Empty; + slug = string.Empty; + queryShapeHash = string.Empty; + if (!TryFromBase64Url(cursor, out var raw)) return false; + var parts = raw.Split(Sep); + if (parts.Length != 3) return false; + if (!TryFromBase64Url(parts[0], out sortKey)) return false; + if (!TryFromBase64Url(parts[1], out slug)) return false; + if (!TryFromBase64Url(parts[2], out queryShapeHash)) return false; + return true; + } + // ── base64url (RFC 4648 §5) without external dependencies ── private static string ToBase64Url(string value) diff --git a/src/SimPle.Application/DependencyInjection.cs b/src/SimPle.Application/DependencyInjection.cs index 9f29739..4545f94 100644 --- a/src/SimPle.Application/DependencyInjection.cs +++ b/src/SimPle.Application/DependencyInjection.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using SimPle.Application.Auth.Services; using SimPle.Application.Friends.Services; +using SimPle.Application.Games.Services; using SimPle.Application.People.Services; using SimPle.Application.Profiles.Services; @@ -14,6 +15,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/src/SimPle.Application/Games/DTOs/GameCatalogDto.cs b/src/SimPle.Application/Games/DTOs/GameCatalogDto.cs new file mode 100644 index 0000000..bbe177e --- /dev/null +++ b/src/SimPle.Application/Games/DTOs/GameCatalogDto.cs @@ -0,0 +1,28 @@ +namespace SimPle.Application.Games.DTOs; + +/// +/// Public catalog/detail/featured DTO. Auth-independent — byte-identical for anonymous and authenticated +/// callers, which is what makes Cache-Control: public, max-age=60 safe (see spec D1). Deliberately +/// excludes isFavorited, online/presence counts, lastPlayed, stats, and ELO — favorite state +/// comes only from the private favorites endpoint and is merged client-side. +/// +public sealed record GameCatalogDto( + string Slug, + string Name, + string Summary, + string RulesSummary, + string Category, + IReadOnlyList Tags, + string Difficulty, + int EstimatedDurationMinMinutes, + int EstimatedDurationMaxMinutes, + int MinPlayers, + int MaxPlayers, + string Lifecycle, + IReadOnlyList Capabilities, + int? FeaturedRank, + string ArtToken, + string ArtColorA, + string ArtColorB, + string ArtAltText, + IReadOnlyList EntryActions); diff --git a/src/SimPle.Application/Games/DTOs/GameEntryActionDto.cs b/src/SimPle.Application/Games/DTOs/GameEntryActionDto.cs new file mode 100644 index 0000000..15638bf --- /dev/null +++ b/src/SimPle.Application/Games/DTOs/GameEntryActionDto.cs @@ -0,0 +1,8 @@ +namespace SimPle.Application.Games.DTOs; + +/// +/// One entry-point action on a catalog/detail DTO. In M4 every action is deferred — no engine exists +/// yet — and names the module that will flip it to enabled after its own +/// backend and E2E gate pass. Never mutated per-request; the same fixed set is projected for every game. +/// +public sealed record GameEntryActionDto(string Action, string Status, string ReasonCode, int OwnerModule); diff --git a/src/SimPle.Application/Games/DTOs/GameFavoriteDto.cs b/src/SimPle.Application/Games/DTOs/GameFavoriteDto.cs new file mode 100644 index 0000000..52f13a7 --- /dev/null +++ b/src/SimPle.Application/Games/DTOs/GameFavoriteDto.cs @@ -0,0 +1,14 @@ +namespace SimPle.Application.Games.DTOs; + +/// +/// Private favorites-list / favorite-mutation DTO. Never shared-cached (private, no-store). +/// +public sealed record GameFavoriteDto( + string Slug, + string Name, + string Lifecycle, + string ArtToken, + string ArtColorA, + string ArtColorB, + string ArtAltText, + DateTime FavoritedAt); diff --git a/src/SimPle.Application/Games/DTOs/GameTombstoneDto.cs b/src/SimPle.Application/Games/DTOs/GameTombstoneDto.cs new file mode 100644 index 0000000..1229fd4 --- /dev/null +++ b/src/SimPle.Application/Games/DTOs/GameTombstoneDto.cs @@ -0,0 +1,7 @@ +namespace SimPle.Application.Games.DTOs; + +/// +/// Minimal shape returned for a Retired game detail read (410). Carries only enough for the client to render +/// an honest "this game is retired" state — no summary, no tags, no capabilities, no art. +/// +public sealed record GameTombstoneDto(string Slug, string Name, string Lifecycle, string ReasonCode); diff --git a/src/SimPle.Application/Games/FavoriteOutcomes.cs b/src/SimPle.Application/Games/FavoriteOutcomes.cs new file mode 100644 index 0000000..7b5e297 --- /dev/null +++ b/src/SimPle.Application/Games/FavoriteOutcomes.cs @@ -0,0 +1,5 @@ +namespace SimPle.Application.Games; + +public enum AddFavoriteOutcome { Added, Conflict } + +public enum UpdateFavoriteOutcome { Updated, ConcurrencyConflict } diff --git a/src/SimPle.Application/Games/GameCatalogSortKey.cs b/src/SimPle.Application/Games/GameCatalogSortKey.cs new file mode 100644 index 0000000..001827f --- /dev/null +++ b/src/SimPle.Application/Games/GameCatalogSortKey.cs @@ -0,0 +1,61 @@ +using SimPle.Domain.Games; + +namespace SimPle.Application.Games; + +/// +/// Shared encode/decode contract for the four allow-listed catalog sorts' opaque cursor sort-key (paired with +/// / +/// ). +/// encodes the last-emitted row's key into the outgoing cursor; GameRepository decodes an incoming +/// cursor's key back into the typed keyset predicate for whichever sort is active. +/// +public static class GameCatalogSortKey +{ + public const string Default = "default"; + public const string Name = "name"; + public const string Difficulty = "difficulty"; + public const string Duration = "duration"; + + public static readonly IReadOnlyList AllowedSorts = new[] { Default, Name, Difficulty, Duration }; + + /// + /// Stands in for a null so the default sort's key is a single, totally + /// ordered, fixed-width string (Postgres ASC already sorts NULLS LAST, which this sentinel mirrors: it is + /// larger than any real rank, so encoded null rows sort after every real rank when compared as text). + /// + public const int NoFeaturedRank = int.MaxValue; + + public static string Encode(string sort, Game game) => sort switch + { + Default => $"{(game.FeaturedRank ?? NoFeaturedRank):D10}{game.SortOrder:D10}", + Name => game.Name.ToUpperInvariant(), + Difficulty => $"{DifficultyRank(game.Difficulty):D2}", + Duration => $"{game.EstimatedDurationMinMinutes:D10}", + _ => throw new ArgumentOutOfRangeException(nameof(sort), sort, "Unknown sort."), + }; + + /// Easy/Medium/Hard progression, independent of the column's string storage (see GameConfiguration). + public static int DifficultyRank(GameDifficulty difficulty) => difficulty switch + { + GameDifficulty.Easy => 0, + GameDifficulty.Medium => 1, + GameDifficulty.Hard => 2, + _ => throw new ArgumentOutOfRangeException(nameof(difficulty), difficulty, "Unknown difficulty."), + }; + + public static bool TryDecodeDefault(string sortKey, out int featuredRank, out int sortOrder) + { + featuredRank = default; + sortOrder = default; + if (sortKey.Length != 20) return false; + if (!int.TryParse(sortKey.AsSpan(0, 10), out featuredRank)) return false; + if (!int.TryParse(sortKey.AsSpan(10, 10), out sortOrder)) return false; + return true; + } + + public static bool TryDecodeDifficulty(string sortKey, out int rank) => + int.TryParse(sortKey, out rank) && rank is >= 0 and <= 2; + + public static bool TryDecodeDuration(string sortKey, out int minutes) => + int.TryParse(sortKey, out minutes) && minutes >= 0; +} diff --git a/src/SimPle.Application/Games/GameEntryActions.cs b/src/SimPle.Application/Games/GameEntryActions.cs new file mode 100644 index 0000000..32e6994 --- /dev/null +++ b/src/SimPle.Application/Games/GameEntryActions.cs @@ -0,0 +1,20 @@ +using SimPle.Application.Games.DTOs; + +namespace SimPle.Application.Games; + +/// +/// The fixed set of entry-point actions projected onto every catalog/detail DTO. In M4 every action is +/// deferred — no game engine exists yet — per the spec's entry-actions table. A later module flips its +/// own action to enabled only after its backend and E2E gate pass; this list is never mutated per-request. +/// +public static class GameEntryActions +{ + public static readonly IReadOnlyList All = new[] + { + new GameEntryActionDto("play-vs-ai", "deferred", "Games.EntryDeferred.AI", 9), + new GameEntryActionDto("quick-match", "deferred", "Games.EntryDeferred.QuickMatch", 6), + new GameEntryActionDto("create-lobby", "deferred", "Games.EntryDeferred.Lobby", 6), + new GameEntryActionDto("invite-friend", "deferred", "Games.EntryDeferred.Invite", 6), + new GameEntryActionDto("enter-match-room", "deferred", "Games.EntryDeferred.MatchRoom", 8), + }; +} diff --git a/src/SimPle.Application/Games/Outbox/GameOutbox.cs b/src/SimPle.Application/Games/Outbox/GameOutbox.cs new file mode 100644 index 0000000..5547d26 --- /dev/null +++ b/src/SimPle.Application/Games/Outbox/GameOutbox.cs @@ -0,0 +1,52 @@ +using System.Text.Json; +using SimPle.Domain.Games; +using SimPle.Domain.Outbox; + +namespace SimPle.Application.Games.Outbox; + +/// +/// Builds the three Module 4 integration events as immutable rows, mirroring +/// . Every payload carries minimum ids only — no +/// search text, no favorite contents. Each message is staged inside the same transaction as the aggregate +/// mutation; the unique (AggregateId, EventType, AggregateDomainVersion) index makes a retried transition +/// idempotent. No consumer exists yet (arrives with M7/M10/M11) — that is expected, not a gap. +/// +public static class GameOutbox +{ + public const int EventVersion = 1; + private const string FavoriteAggregate = "UserFavoriteGame"; + private const string GameAggregate = "Game"; + + public const string GameFavorited = "GameFavoritedV1"; + public const string GameUnfavorited = "GameUnfavoritedV1"; + public const string GameLifecycleChanged = "GameLifecycleChangedV1"; + + public static OutboxMessage GameFavoritedEvent(UserFavoriteGame favorite) => + Favorite(favorite, GameFavorited); + + public static OutboxMessage GameUnfavoritedEvent(UserFavoriteGame favorite) => + Favorite(favorite, GameUnfavorited); + + public static OutboxMessage GameLifecycleChangedEvent(Game game) => + OutboxMessage.Create( + GameAggregate, game.Id, GameLifecycleChanged, EventVersion, + aggregateDomainVersion: game.LifecycleVersion, requestCycleId: game.LifecycleVersion, + Serialize(new + { + gameId = game.Id, + lifecycle = game.Lifecycle.ToString(), + })); + + private static OutboxMessage Favorite(UserFavoriteGame favorite, string eventType) => + OutboxMessage.Create( + FavoriteAggregate, favorite.Id, eventType, EventVersion, + aggregateDomainVersion: favorite.CycleId, requestCycleId: favorite.CycleId, + Serialize(new + { + favoriteId = favorite.Id, + userId = favorite.UserId, + gameId = favorite.GameId, + })); + + private static string Serialize(object payload) => JsonSerializer.Serialize(payload); +} diff --git a/src/SimPle.Application/Games/Services/GamesService.cs b/src/SimPle.Application/Games/Services/GamesService.cs new file mode 100644 index 0000000..543101f --- /dev/null +++ b/src/SimPle.Application/Games/Services/GamesService.cs @@ -0,0 +1,317 @@ +using System.Security.Cryptography; +using System.Text; +using SimPle.Application.Common.Interfaces; +using SimPle.Application.Common.Pagination; +using SimPle.Application.Games.DTOs; +using SimPle.Application.Games.Outbox; +using SimPle.Domain.Games; +using SimPle.Shared.Common; + +namespace SimPle.Application.Games.Services; + +public sealed class GamesService : IGamesService +{ + private readonly IGameRepository _games; + + private const int MaxLimit = 50; + private const int DefaultLimit = 24; + + /// + /// Fixed cap on how many values a single multi-value filter (category/tag/mode/lifecycle) may carry. + /// Not specified numerically by the spec ("filter cardinality exceeded" is named as a 400 case without a + /// number) — 5 is a documented design decision: every allow-list itself has at most 8 members, and a + /// filter wider than that stops narrowing the catalog at all, so 5 comfortably covers real use without + /// letting a request carry unbounded repeated query keys. + /// + private const int MaxFilterValues = 5; + + private const string ValidationFailed = "Validation.Failed"; + private const string InvalidCursor = "Pagination.InvalidCursor"; + private const string NotFound = "Games.NotFound"; + private const string Retired = "Games.Retired"; + + private static readonly IReadOnlyDictionary PublicLifecycleNames = + new Dictionary(StringComparer.Ordinal) + { + ["ComingSoon"] = GameLifecycle.ComingSoon, + ["Available"] = GameLifecycle.Available, + ["Maintenance"] = GameLifecycle.Maintenance, + }; + + private static readonly IReadOnlyList PublicLifecycles = + new[] { GameLifecycle.ComingSoon, GameLifecycle.Available, GameLifecycle.Maintenance }; + + public GamesService(IGameRepository games) => _games = games; + + public async Task> ListAsync( + string? query, + IReadOnlyList? category, + IReadOnlyList? tag, + IReadOnlyList? mode, + IReadOnlyList? lifecycle, + string? sort, + int limit, + string? cursor, + CancellationToken ct = default) + { + string? normalizedSearch = null; + if (!string.IsNullOrWhiteSpace(query)) + { + var collapsed = string.Join(' ', query.Split(' ', StringSplitOptions.RemoveEmptyEntries)).Trim(); + if (collapsed.Length < 2 || collapsed.Length > 100) + return Fail("Search query must be between 2 and 100 characters."); + normalizedSearch = collapsed.ToUpperInvariant(); + } + + var categories = category ?? Array.Empty(); + var tags = tag ?? Array.Empty(); + var modes = mode ?? Array.Empty(); + var lifecycleValues = lifecycle ?? Array.Empty(); + + if (categories.Count > MaxFilterValues || tags.Count > MaxFilterValues || + modes.Count > MaxFilterValues || lifecycleValues.Count > MaxFilterValues) + return Fail("Too many values for a single filter."); + + if (categories.Any(c => !GameCatalogAllowLists.Tags.Contains(c))) + return Fail("Unknown category filter."); + if (tags.Any(t => !GameCatalogAllowLists.Tags.Contains(t))) + return Fail("Unknown tag filter."); + if (modes.Any(m => !GameCatalogAllowLists.Modes.Contains(m))) + return Fail("Unknown mode filter."); + + IReadOnlyList lifecycles; + if (lifecycleValues.Count == 0) + { + lifecycles = PublicLifecycles; + } + else + { + var parsed = new List(lifecycleValues.Count); + foreach (var lv in lifecycleValues) + { + if (!PublicLifecycleNames.TryGetValue(lv, out var parsedLifecycle)) + return Fail("Unknown or non-public lifecycle filter."); + parsed.Add(parsedLifecycle); + } + lifecycles = parsed; + } + + var activeSort = string.IsNullOrEmpty(sort) ? GameCatalogSortKey.Default : sort; + if (!GameCatalogSortKey.AllowedSorts.Contains(activeSort)) + return Fail("Unknown sort."); + + if (limit < 1 || limit > MaxLimit) + return Fail("Page size must be between 1 and 50."); + + var shapeHash = HashQueryShape(normalizedSearch, categories, tags, modes, lifecycles, activeSort); + + string? afterSortKey = null; + string? afterSlug = null; + if (cursor is not null) + { + if (!Cursor.TryDecodeCatalog(cursor, out var sortKey, out var slug, out var cursorShapeHash) || + cursorShapeHash != shapeHash || slug.Length == 0) + return Result.Fail(InvalidCursor, "The pagination cursor is invalid."); + + // Reject a cursor whose sort key cannot decode under the active sort now, rather than letting the + // repository silently drop the keyset predicate and quietly restart at page 1. + var sortKeyValid = activeSort switch + { + GameCatalogSortKey.Difficulty => GameCatalogSortKey.TryDecodeDifficulty(sortKey, out _), + GameCatalogSortKey.Duration => GameCatalogSortKey.TryDecodeDuration(sortKey, out _), + GameCatalogSortKey.Default => GameCatalogSortKey.TryDecodeDefault(sortKey, out _, out _), + _ => sortKey.Length > 0, // Name: any non-empty uppercased string is a valid key + }; + if (!sortKeyValid) + return Result.Fail(InvalidCursor, "The pagination cursor is invalid."); + + afterSortKey = sortKey; + afterSlug = slug; + } + + var filter = new GameCatalogFilter( + normalizedSearch, categories, tags, modes, lifecycles, activeSort, limit, afterSortKey, afterSlug); + var rows = await _games.GetCatalogPageAsync(filter, ct); + + var items = await ProjectAsync(rows, ct); + + string? next = rows.Count == limit + ? Cursor.EncodeCatalog(GameCatalogSortKey.Encode(activeSort, rows[^1]), rows[^1].Slug, shapeHash) + : null; + + var etag = ComputeListETag(rows); + return Result.Ok(new CatalogPageResult(new CursorPage(items, next), etag)); + } + + public async Task> GetDetailAsync(string slug, CancellationToken ct = default) + { + var game = await _games.GetBySlugAsync(slug, ct); + if (game is null || game.Lifecycle == GameLifecycle.Draft) + return Result.Fail(NotFound, "Game not found."); + + if (game.Lifecycle == GameLifecycle.Retired) + { + var tombstone = new GameTombstoneDto(game.Slug, game.Name, game.Lifecycle.ToString(), "Games.Retired"); + return Result.Ok(new GameDetailResult(null, tombstone, null)); + } + + var items = await ProjectAsync(new[] { game }, ct); + return Result.Ok(new GameDetailResult(items[0], null, ComputeSingleETag(game))); + } + + public async Task> GetFeaturedAsync(CancellationToken ct = default) + { + var game = await _games.GetFeaturedAsync(ct); + if (game is null) + return Result.Ok(new FeaturedResult(null, null)); + + var items = await ProjectAsync(new[] { game }, ct); + return Result.Ok(new FeaturedResult(items[0], ComputeSingleETag(game))); + } + + public async Task>> GetFavoritesAsync( + Guid userId, int limit, string? cursor, CancellationToken ct = default) + { + if (limit < 1 || limit > MaxLimit) + return Result>.Fail(ValidationFailed, "Page size must be between 1 and 50."); + + DateTime? afterUpdatedAt = null; + Guid? afterId = null; + if (cursor is not null) + { + if (!Cursor.TryDecodeTimeId(cursor, out var updatedAt, out var id)) + return Result>.Fail(InvalidCursor, "The pagination cursor is invalid."); + afterUpdatedAt = updatedAt; + afterId = id; + } + + var rows = await _games.GetFavoritesPageAsync(userId, limit, afterUpdatedAt, afterId, ct); + var items = rows.Select(x => ToFavoriteDto(x.Favorite, x.Game)).ToList(); + + string? next = rows.Count == limit + ? Cursor.EncodeTimeId(rows[^1].Favorite.UpdatedAt, rows[^1].Favorite.Id) + : null; + + return Result>.Ok(new CursorPage(items, next)); + } + + public async Task> PutFavoriteAsync(Guid userId, string slug, CancellationToken ct = default) + { + var game = await _games.GetBySlugAsync(slug, ct); + if (game is null || game.Lifecycle == GameLifecycle.Draft) + return Result.Fail(NotFound, "Game not found."); + + var existing = await _games.GetFavoriteAsync(userId, game.Id, ct); + if (existing is not null && existing.IsActive) + return Result.Ok(ToFavoriteDto(existing, game)); + + if (game.Lifecycle == GameLifecycle.Retired) + return Result.Fail(Retired, "This game is retired and cannot be favorited."); + + if (existing is null) + { + var favorite = UserFavoriteGame.Favorite(userId, game.Id); + var outcome = await _games.AddFavoriteAsync(favorite, GameOutbox.GameFavoritedEvent(favorite), ct); + if (outcome == AddFavoriteOutcome.Conflict) + { + // A concurrent first favorite already won the (UserId, GameId) race; re-read and converge. + var reread = await _games.GetFavoriteAsync(userId, game.Id, ct); + if (reread is not null) + return Result.Ok(ToFavoriteDto(reread, game)); + return Result.Fail(NotFound, "Game not found."); + } + return Result.Ok(ToFavoriteDto(favorite, game)); + } + + existing.Refavorite(); + var updateOutcome = await _games.UpdateFavoriteAsync(existing, GameOutbox.GameFavoritedEvent(existing), ct); + if (updateOutcome == UpdateFavoriteOutcome.ConcurrencyConflict) + { + var reread = await _games.GetFavoriteAsync(userId, game.Id, ct); + if (reread is not null) + return Result.Ok(ToFavoriteDto(reread, game)); + } + return Result.Ok(ToFavoriteDto(existing, game)); + } + + public async Task DeleteFavoriteAsync(Guid userId, string slug, CancellationToken ct = default) + { + var game = await _games.GetBySlugAsync(slug, ct); + if (game is null || game.Lifecycle == GameLifecycle.Draft) + return Result.Fail(NotFound, "Game not found."); + + var existing = await _games.GetFavoriteAsync(userId, game.Id, ct); + if (existing is null || !existing.IsActive) + return Result.Ok(); // Idempotent: already unfavorited (or never favorited) is still a success. + + existing.Unfavorite(); + // A ConcurrencyConflict here just means a racing caller already unfavorited it — also fine for a 204. + await _games.UpdateFavoriteAsync(existing, GameOutbox.GameUnfavoritedEvent(existing), ct); + return Result.Ok(); + } + + // ── Projection (query 2 of 2: tags + capabilities in one UNION ALL round trip) ── + + private async Task> ProjectAsync(IReadOnlyList games, CancellationToken ct) + { + var ids = games.Select(g => g.Id).ToList(); + var extra = await _games.GetTagsAndCapabilitiesAsync(ids, ct); + + var tagsByGame = extra.Where(r => r.Kind == "tag") + .GroupBy(r => r.GameId) + .ToDictionary(g => g.Key, g => (IReadOnlyList)g.Select(r => r.Value).ToList()); + var modesByGame = extra.Where(r => r.Kind == "mode") + .GroupBy(r => r.GameId) + .ToDictionary(g => g.Key, g => (IReadOnlyList)g.Select(r => r.Value).ToList()); + + return games.Select(g => new GameCatalogDto( + g.Slug, g.Name, g.Summary, g.RulesSummary, g.Category, + tagsByGame.TryGetValue(g.Id, out var t) ? t : Array.Empty(), + g.Difficulty.ToString(), + g.EstimatedDurationMinMinutes, g.EstimatedDurationMaxMinutes, g.MinPlayers, g.MaxPlayers, + g.Lifecycle.ToString(), + modesByGame.TryGetValue(g.Id, out var m) ? m : Array.Empty(), + g.FeaturedRank, g.ArtToken, g.ArtColorA, g.ArtColorB, g.ArtAltText, + GameEntryActions.All)).ToList(); + } + + private static GameFavoriteDto ToFavoriteDto(UserFavoriteGame favorite, Game game) => new( + game.Slug, game.Name, game.Lifecycle.ToString(), + game.ArtToken, game.ArtColorA, game.ArtColorB, game.ArtAltText, favorite.UpdatedAt); + + // ── ETag derivation (documented deviation from the spec's literal single global catalogVersion counter: + // a hash of the already-fetched row data avoids both a third SQL round trip and an unsafe in-process + // counter cache; two requests get the same ETag iff they'd return byte-identical bodies) ── + + private static string ComputeListETag(IReadOnlyList rows) + { + var sb = new StringBuilder(); + foreach (var g in rows) + sb.Append(g.Slug).Append('|').Append(g.UpdatedAt.Ticks).Append('|').Append(g.LifecycleVersion).Append(';'); + return Quote(Hash(sb.ToString())); + } + + private static string ComputeSingleETag(Game g) => + Quote(Hash($"{g.Slug}|{g.UpdatedAt.Ticks}|{g.LifecycleVersion}")); + + private static string HashQueryShape( + string? normalizedSearch, IReadOnlyList categories, IReadOnlyList tags, + IReadOnlyList modes, IReadOnlyList lifecycles, string sort) + { + var shape = string.Join('&', + $"q={normalizedSearch}", + $"cat={string.Join(',', categories.OrderBy(x => x, StringComparer.Ordinal))}", + $"tag={string.Join(',', tags.OrderBy(x => x, StringComparer.Ordinal))}", + $"mode={string.Join(',', modes.OrderBy(x => x, StringComparer.Ordinal))}", + $"life={string.Join(',', lifecycles.Select(l => l.ToString()).OrderBy(x => x, StringComparer.Ordinal))}", + $"sort={sort}"); + return Hash(shape); + } + + private static string Hash(string value) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))); + + private static string Quote(string value) => $"\"{value}\""; + + private static Result Fail(string message) => + Result.Fail(ValidationFailed, message); +} diff --git a/src/SimPle.Application/Games/Services/IGamesService.cs b/src/SimPle.Application/Games/Services/IGamesService.cs new file mode 100644 index 0000000..01e9c3e --- /dev/null +++ b/src/SimPle.Application/Games/Services/IGamesService.cs @@ -0,0 +1,41 @@ +using SimPle.Application.Games.DTOs; +using SimPle.Shared.Common; + +namespace SimPle.Application.Games.Services; + +/// +/// A successful detail read is exactly one of (200) or (410 — the +/// slug exists but is Retired). is populated only alongside : the spec's +/// cache headers apply to 200/304 responses only, never to 410. +/// +public sealed record GameDetailResult(GameCatalogDto? Game, GameTombstoneDto? Tombstone, string? ETag); + +/// A successful featured read: is null when no game currently holds FeaturedRank 1 (204). +public sealed record FeaturedResult(GameCatalogDto? Game, string? ETag); + +public sealed record CatalogPageResult(CursorPage Page, string ETag); + +public interface IGamesService +{ + Task> ListAsync( + string? query, + IReadOnlyList? category, + IReadOnlyList? tag, + IReadOnlyList? mode, + IReadOnlyList? lifecycle, + string? sort, + int limit, + string? cursor, + CancellationToken ct = default); + + Task> GetDetailAsync(string slug, CancellationToken ct = default); + + Task> GetFeaturedAsync(CancellationToken ct = default); + + Task>> GetFavoritesAsync( + Guid userId, int limit, string? cursor, CancellationToken ct = default); + + Task> PutFavoriteAsync(Guid userId, string slug, CancellationToken ct = default); + + Task DeleteFavoriteAsync(Guid userId, string slug, CancellationToken ct = default); +} diff --git a/src/SimPle.Infrastructure/Persistence/Repositories/GameRepository.cs b/src/SimPle.Infrastructure/Persistence/Repositories/GameRepository.cs new file mode 100644 index 0000000..626c823 --- /dev/null +++ b/src/SimPle.Infrastructure/Persistence/Repositories/GameRepository.cs @@ -0,0 +1,202 @@ +using Microsoft.EntityFrameworkCore; +using Npgsql; +using SimPle.Application.Common.Interfaces; +using SimPle.Application.Games; +using SimPle.Domain.Games; +using SimPle.Domain.Outbox; + +namespace SimPle.Infrastructure.Persistence.Repositories; + +public sealed class GameRepository : IGameRepository +{ + private readonly AppDbContext _db; + + public GameRepository(AppDbContext db) => _db = db; + + private static bool IsUniqueViolation(DbUpdateException ex) => + ex.InnerException is PostgresException pg && pg.SqlState == PostgresErrorCodes.UniqueViolation; + + // ── Catalog reads (query 1 of 2 — the game rows themselves) ──────────────── + + public async Task> GetCatalogPageAsync(GameCatalogFilter filter, CancellationToken ct = default) + { + var query = _db.Games.AsNoTracking().Where(g => filter.Lifecycles.Contains(g.Lifecycle)); + + if (filter.Categories.Count > 0) + query = query.Where(g => filter.Categories.Contains(g.Category)); + + if (filter.Tags.Count > 0) + query = query.Where(g => _db.GameTags.Any(t => t.GameId == g.Id && filter.Tags.Contains(t.Value))); + + if (filter.Modes.Count > 0) + query = query.Where(g => _db.GameModeCapabilities.Any(c => c.GameId == g.Id && filter.Modes.Contains(c.Mode))); + + if (filter.NormalizedSearch is string search) + { + query = query.Where(g => + g.Name.ToUpper().Contains(search) || + g.Summary.ToUpper().Contains(search) || + g.Category.ToUpper().Contains(search) || + _db.GameTags.Any(t => t.GameId == g.Id && t.Value.ToUpper().Contains(search))); + } + + switch (filter.Sort) + { + case GameCatalogSortKey.Name: + { + if (filter.AfterSortKey is string afterName && filter.AfterSlug is string afterSlugN) + { + query = query.Where(g => + g.Name.ToUpper().CompareTo(afterName) > 0 || + (g.Name.ToUpper() == afterName && g.Slug.CompareTo(afterSlugN) > 0)); + } + return await query.OrderBy(g => g.Name.ToUpper()).ThenBy(g => g.Slug) + .Take(filter.Limit).ToListAsync(ct); + } + case GameCatalogSortKey.Difficulty: + { + if (filter.AfterSortKey is string afterDiffStr && filter.AfterSlug is string afterSlugD && + GameCatalogSortKey.TryDecodeDifficulty(afterDiffStr, out var afterDiff)) + { + query = query.Where(g => + (g.Difficulty == GameDifficulty.Easy ? 0 : g.Difficulty == GameDifficulty.Hard ? 2 : 1) > afterDiff || + ((g.Difficulty == GameDifficulty.Easy ? 0 : g.Difficulty == GameDifficulty.Hard ? 2 : 1) == afterDiff && + g.Slug.CompareTo(afterSlugD) > 0)); + } + return await query + .OrderBy(g => g.Difficulty == GameDifficulty.Easy ? 0 : g.Difficulty == GameDifficulty.Hard ? 2 : 1) + .ThenBy(g => g.Slug) + .Take(filter.Limit).ToListAsync(ct); + } + case GameCatalogSortKey.Duration: + { + if (filter.AfterSortKey is string afterDurStr && filter.AfterSlug is string afterSlugU && + GameCatalogSortKey.TryDecodeDuration(afterDurStr, out var afterDur)) + { + query = query.Where(g => + g.EstimatedDurationMinMinutes > afterDur || + (g.EstimatedDurationMinMinutes == afterDur && g.Slug.CompareTo(afterSlugU) > 0)); + } + return await query.OrderBy(g => g.EstimatedDurationMinMinutes).ThenBy(g => g.Slug) + .Take(filter.Limit).ToListAsync(ct); + } + default: // GameCatalogSortKey.Default: (FeaturedRank NULLS LAST, SortOrder, Slug) + { + if (filter.AfterSortKey is string afterKey && filter.AfterSlug is string afterSlugDef && + GameCatalogSortKey.TryDecodeDefault(afterKey, out var afterRank, out var afterSortOrder)) + { + if (afterRank == GameCatalogSortKey.NoFeaturedRank) + { + // Prior row was already in the null tail — only later null rows remain. + query = query.Where(g => + g.FeaturedRank == null && + (g.SortOrder > afterSortOrder || (g.SortOrder == afterSortOrder && g.Slug.CompareTo(afterSlugDef) > 0))); + } + else + { + // Prior row had a real rank: later real ranks, ties on (SortOrder, Slug), or the null tail. + query = query.Where(g => + g.FeaturedRank == null || + g.FeaturedRank > afterRank || + (g.FeaturedRank == afterRank && + (g.SortOrder > afterSortOrder || (g.SortOrder == afterSortOrder && g.Slug.CompareTo(afterSlugDef) > 0)))); + } + } + return await query.OrderBy(g => g.FeaturedRank).ThenBy(g => g.SortOrder).ThenBy(g => g.Slug) + .Take(filter.Limit).ToListAsync(ct); + } + } + } + + // ── Catalog reads (query 2 of 2 — tags + capabilities, one UNION ALL round trip) ── + + public async Task> GetTagsAndCapabilitiesAsync( + IReadOnlyList gameIds, CancellationToken ct = default) + { + if (gameIds.Count == 0) return Array.Empty<(Guid, string, string)>(); + + var tagRows = _db.GameTags + .Where(t => gameIds.Contains(t.GameId)) + .Select(t => new { t.GameId, Kind = "tag", Value = t.Value }); + + var capRows = _db.GameModeCapabilities + .Where(c => gameIds.Contains(c.GameId)) + .Select(c => new { c.GameId, Kind = "mode", Value = c.Mode }); + + // EF translates Concat over two queries against the same context into a single UNION ALL statement. + var rows = await tagRows.Concat(capRows).ToListAsync(ct); + return rows.Select(r => (r.GameId, r.Kind, r.Value)).ToList(); + } + + public Task GetBySlugAsync(string slug, CancellationToken ct = default) => + _db.Games.AsNoTracking().FirstOrDefaultAsync(g => g.Slug == slug, ct); + + public Task GetFeaturedAsync(CancellationToken ct = default) => + _db.Games.AsNoTracking().FirstOrDefaultAsync(g => g.FeaturedRank == 1, ct); + + // ── Favorites ──────────────────────────────────────────────────────────── + + public async Task> GetFavoritesPageAsync( + Guid userId, int limit, DateTime? afterFavoritedAt, Guid? afterId, CancellationToken ct = default) + { + var query = _db.UserFavoriteGames.AsNoTracking().Where(f => f.UserId == userId && f.IsActive); + + if (afterFavoritedAt is DateTime afa && afterId is Guid ai) + { + query = query.Where(f => f.UpdatedAt < afa || (f.UpdatedAt == afa && f.Id.CompareTo(ai) < 0)); + } + + var rows = await query + .OrderByDescending(f => f.UpdatedAt).ThenByDescending(f => f.Id) + .Take(limit) + .Join(_db.Games, f => f.GameId, g => g.Id, (f, g) => new { f, g }) + .OrderByDescending(x => x.f.UpdatedAt).ThenByDescending(x => x.f.Id) + .ToListAsync(ct); + + return rows.Select(x => (x.f, x.g)).ToList(); + } + + public Task GetFavoriteAsync(Guid userId, Guid gameId, CancellationToken ct = default) => + _db.UserFavoriteGames.FirstOrDefaultAsync(f => f.UserId == userId && f.GameId == gameId, ct); + + public async Task AddFavoriteAsync( + UserFavoriteGame favorite, OutboxMessage evt, CancellationToken ct = default) + { + try + { + await _db.UserFavoriteGames.AddAsync(favorite, ct); + await _db.OutboxMessages.AddAsync(evt, ct); + await PostgresRetry.SaveChangesAsync(_db, ct); + return AddFavoriteOutcome.Added; + } + catch (DbUpdateException ex) when (IsUniqueViolation(ex)) + { + Detach(favorite, evt); + return AddFavoriteOutcome.Conflict; + } + } + + public async Task UpdateFavoriteAsync( + UserFavoriteGame favorite, OutboxMessage evt, CancellationToken ct = default) + { + try + { + _db.UserFavoriteGames.Update(favorite); + await _db.OutboxMessages.AddAsync(evt, ct); + await PostgresRetry.SaveChangesAsync(_db, ct); + return UpdateFavoriteOutcome.Updated; + } + catch (DbUpdateException) + { + // Outbox uniqueness rejected a retried transition, or a racing writer won: re-read and converge. + Detach(favorite, evt); + return UpdateFavoriteOutcome.ConcurrencyConflict; + } + } + + private void Detach(UserFavoriteGame f, OutboxMessage evt) + { + _db.Entry(f).State = EntityState.Detached; + _db.Entry(evt).State = EntityState.Detached; + } +} From 32c3d583a55a93ab4452659e258a19ea7bd0645a Mon Sep 17 00:00:00 2001 From: Mohannad Ehab <157999295+MohanEhab@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:21:25 +0300 Subject: [PATCH 3/3] test(module-04-games): add domain, service, and endpoint tests Unit tests for the Game/UserFavoriteGame domain, GamesService, and cursor codec; integration tests for migrations, the seeder (advisory lock, checksum, rerun idempotency), HTTP contract, and real-Postgres concurrency (favorite races, seed races). --- .../Games/GameCatalogMigrationTests.cs | 276 ++++++++++ .../Games/GameCatalogSeederTests.cs | 181 +++++++ .../Games/GamesEndpointsTests.cs | 434 +++++++++++++++ .../Games/GamesPostgresConcurrencyTests.cs | 345 ++++++++++++ .../Games/GameCatalogCursorTests.cs | 154 ++++++ tests/SimPle.UnitTests/Games/GameTests.cs | 354 ++++++++++++ .../Games/GamesServiceTests.cs | 511 ++++++++++++++++++ .../Games/UserFavoriteGameTests.cs | 63 +++ 8 files changed, 2318 insertions(+) create mode 100644 tests/SimPle.IntegrationTests/Games/GameCatalogMigrationTests.cs create mode 100644 tests/SimPle.IntegrationTests/Games/GameCatalogSeederTests.cs create mode 100644 tests/SimPle.IntegrationTests/Games/GamesEndpointsTests.cs create mode 100644 tests/SimPle.IntegrationTests/Games/GamesPostgresConcurrencyTests.cs create mode 100644 tests/SimPle.UnitTests/Games/GameCatalogCursorTests.cs create mode 100644 tests/SimPle.UnitTests/Games/GameTests.cs create mode 100644 tests/SimPle.UnitTests/Games/GamesServiceTests.cs create mode 100644 tests/SimPle.UnitTests/Games/UserFavoriteGameTests.cs diff --git a/tests/SimPle.IntegrationTests/Games/GameCatalogMigrationTests.cs b/tests/SimPle.IntegrationTests/Games/GameCatalogMigrationTests.cs new file mode 100644 index 0000000..59f83a0 --- /dev/null +++ b/tests/SimPle.IntegrationTests/Games/GameCatalogMigrationTests.cs @@ -0,0 +1,276 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql; +using SimPle.Domain.Games; +using SimPle.Domain.Users; +using SimPle.Infrastructure.Persistence; +using Xunit; + +namespace SimPle.IntegrationTests.Games; + +/// +/// PostgreSQL-only smoke tests for the AddGameCatalog migration. Skipped unless +/// MIGRATION_TEST_CONNECTION_STRING is set to a running PostgreSQL instance. Each test METHOD gets its own +/// isolated database (xUnit IAsyncLifetime), created in InitializeAsync and dropped in DisposeAsync. +/// +public sealed class GameCatalogMigrationTests : IAsyncLifetime +{ + private readonly string? _masterConn = Environment.GetEnvironmentVariable("MIGRATION_TEST_CONNECTION_STRING"); + private readonly string _dbName = $"simple_games_smoke_{Guid.NewGuid():N}"; + private string? _testConn; + + public async Task InitializeAsync() + { + if (_masterConn is null) return; + + var builder = new NpgsqlConnectionStringBuilder(_masterConn) { Database = _dbName }; + _testConn = builder.ToString(); + + await using var masterConn = new NpgsqlConnection(_masterConn); + await masterConn.OpenAsync(); + await using var createCmd = masterConn.CreateCommand(); + createCmd.CommandText = $"CREATE DATABASE \"{_dbName}\""; + await createCmd.ExecuteNonQueryAsync(); + + await using var db = CreateTestDb(); + await db.Database.MigrateAsync(); + } + + public async Task DisposeAsync() + { + if (_masterConn is null || _testConn is null) return; + + await using var masterConn = new NpgsqlConnection(_masterConn); + await masterConn.OpenAsync(); + + await using var terminateCmd = masterConn.CreateCommand(); + terminateCmd.CommandText = $@" + SELECT pg_terminate_backend(pg_stat_activity.pid) + FROM pg_stat_activity + WHERE pg_stat_activity.datname = '{_dbName}' + AND pid <> pg_backend_pid();"; + await terminateCmd.ExecuteNonQueryAsync(); + + await using var dropCmd = masterConn.CreateCommand(); + dropCmd.CommandText = $"DROP DATABASE IF EXISTS \"{_dbName}\""; + await dropCmd.ExecuteNonQueryAsync(); + } + + private void SkipIfNoPg() => Skip.If(_masterConn is null, + "Set MIGRATION_TEST_CONNECTION_STRING to a PostgreSQL connection string to run smoke tests."); + + private AppDbContext CreateTestDb() => + new(new DbContextOptionsBuilder().UseNpgsql(_testConn).Options); + + private async Task SeedUserAsync() + { + await using var db = CreateTestDb(); + var g = Guid.NewGuid(); + var user = User.Create($"gsmk{g:N}"[..24], $"gsmk{g:N}@test.io", "hash", "Smoke User"); + db.Users.Add(user); + await db.SaveChangesAsync(); + return user; + } + + private static Game ValidComingSoonGame(string slug, int? featuredRank = null) => Game.Create( + slug, "Test Game", "Summary.", "Rules.", GameDifficulty.Easy, + 1, 5, 1, 2, GameLifecycle.ComingSoon, featuredRank, 0, + "art-token", "#111111", "#222222", "Test Game abstract game artwork", "2026.1", + "strategy", new[] { "puzzle" }, new[] { "solo" }); + + // ── Migration health ───────────────────────────────────────────────────── + + [SkippableFact] + public async Task Migration_AppliesCleanly() + { + SkipIfNoPg(); + + await using var conn = new NpgsqlConnection(_testConn); + await conn.OpenAsync(); + + foreach (var table in new[] + { "games", "game_tags", "game_mode_capabilities", "user_favorite_games", "catalog_seed_history" }) + { + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM information_schema.tables WHERE table_name = @t"; + cmd.Parameters.AddWithValue("t", table); + Assert.Equal(1L, (long)(await cmd.ExecuteScalarAsync())!); + } + + await using var indexCmd = conn.CreateCommand(); + indexCmd.CommandText = "SELECT COUNT(*) FROM pg_indexes WHERE tablename = 'games' AND indexname = @i"; + indexCmd.Parameters.AddWithValue("i", "ux_games_featured_rank_one"); + Assert.Equal(1L, (long)(await indexCmd.ExecuteScalarAsync())!); + } + + // ── Partial unique index: at most one FeaturedRank = 1 ──────────────────── + + [SkippableFact] + public async Task PartialUniqueIndex_RejectsSecondFeaturedRankOne() + { + SkipIfNoPg(); + + await using var db = CreateTestDb(); + db.Games.Add(ValidComingSoonGame("game-one", featuredRank: 1)); + await db.SaveChangesAsync(); + + db.Games.Add(ValidComingSoonGame("game-two", featuredRank: 1)); + var ex = await Assert.ThrowsAsync(() => db.SaveChangesAsync()); + Assert.True(ex.InnerException is PostgresException pg && pg.SqlState == "23505", + $"Expected unique_violation (23505), got: {(ex.InnerException as PostgresException)?.SqlState}"); + } + + // ── CHECK constraint: Draft/Retired cannot be featured ───────────────────── + + [SkippableFact] + public async Task CheckConstraint_RejectsDraftGameWithFeaturedRank() + { + SkipIfNoPg(); + + await using var conn = new NpgsqlConnection(_testConn); + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO games + ("Id", "Slug", "Name", "Summary", "RulesSummary", "Category", "Difficulty", + "EstimatedDurationMinMinutes", "EstimatedDurationMaxMinutes", "MinPlayers", "MaxPlayers", + "Lifecycle", "FeaturedRank", "SortOrder", "ArtToken", "ArtColorA", "ArtColorB", "ArtAltText", + "ManifestVersion", "CreatedAt", "UpdatedAt") + VALUES + (@id, 'draft-featured', 'Draft Featured', 'Summary', 'Rules', 'strategy', 'Easy', + 1, 5, 1, 2, 'Draft', 1, 0, 'art', '#111111', '#222222', 'alt', + '2026.1', now(), now()); + """; + cmd.Parameters.AddWithValue("id", Guid.NewGuid()); + + var ex = await Assert.ThrowsAsync(() => cmd.ExecuteNonQueryAsync()); + Assert.Equal("23514", ex.SqlState); + } + + [SkippableFact] + public async Task CheckConstraint_RejectsRetiredGameWithFeaturedRank() + { + SkipIfNoPg(); + + await using var conn = new NpgsqlConnection(_testConn); + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO games + ("Id", "Slug", "Name", "Summary", "RulesSummary", "Category", "Difficulty", + "EstimatedDurationMinMinutes", "EstimatedDurationMaxMinutes", "MinPlayers", "MaxPlayers", + "Lifecycle", "FeaturedRank", "SortOrder", "ArtToken", "ArtColorA", "ArtColorB", "ArtAltText", + "ManifestVersion", "CreatedAt", "UpdatedAt") + VALUES + (@id, 'retired-featured', 'Retired Featured', 'Summary', 'Rules', 'strategy', 'Easy', + 1, 5, 1, 2, 'Retired', 1, 0, 'art', '#111111', '#222222', 'alt', + '2026.1', now(), now()); + """; + cmd.Parameters.AddWithValue("id", Guid.NewGuid()); + + var ex = await Assert.ThrowsAsync(() => cmd.ExecuteNonQueryAsync()); + Assert.Equal("23514", ex.SqlState); + } + + // ── Child-row unique constraints ──────────────────────────────────────── + + [SkippableFact] + public async Task GameTags_UniqueConstraint_RejectsDuplicateValueOnSameGame() + { + SkipIfNoPg(); + + await using var db = CreateTestDb(); + var game = ValidComingSoonGame("tag-dupe-game"); // seeded with tag "puzzle" + db.Games.Add(game); + await db.SaveChangesAsync(); + + await using var conn = new NpgsqlConnection(_testConn); + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO game_tags ("Id", "GameId", "Value", "CreatedAt", "UpdatedAt") + VALUES (@id, @gameId, 'puzzle', now(), now()); + """; + cmd.Parameters.AddWithValue("id", Guid.NewGuid()); + cmd.Parameters.AddWithValue("gameId", game.Id); + + var ex = await Assert.ThrowsAsync(() => cmd.ExecuteNonQueryAsync()); + Assert.Equal("23505", ex.SqlState); + } + + [SkippableFact] + public async Task GameModeCapabilities_UniqueConstraint_RejectsDuplicateModeOnSameGame() + { + SkipIfNoPg(); + + await using var db = CreateTestDb(); + var game = ValidComingSoonGame("mode-dupe-game"); // seeded with mode "solo" + db.Games.Add(game); + await db.SaveChangesAsync(); + + await using var conn = new NpgsqlConnection(_testConn); + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO game_mode_capabilities ("Id", "GameId", "Mode", "CreatedAt", "UpdatedAt") + VALUES (@id, @gameId, 'solo', now(), now()); + """; + cmd.Parameters.AddWithValue("id", Guid.NewGuid()); + cmd.Parameters.AddWithValue("gameId", game.Id); + + var ex = await Assert.ThrowsAsync(() => cmd.ExecuteNonQueryAsync()); + Assert.Equal("23505", ex.SqlState); + } + + [SkippableFact] + public async Task UserFavoriteGames_UniqueConstraint_RejectsDuplicateUserGamePair() + { + SkipIfNoPg(); + + var user = await SeedUserAsync(); + await using var db = CreateTestDb(); + var game = ValidComingSoonGame("favorite-dupe-game"); + db.Games.Add(game); + await db.SaveChangesAsync(); + + db.Set().Add(UserFavoriteGame.Favorite(user.Id, game.Id)); + await db.SaveChangesAsync(); + + db.Set().Add(UserFavoriteGame.Favorite(user.Id, game.Id)); + var ex = await Assert.ThrowsAsync(() => db.SaveChangesAsync()); + Assert.True(ex.InnerException is PostgresException pg && pg.SqlState == "23505"); + } + + // ── Rollback ───────────────────────────────────────────────────────────── + + [SkippableFact] + public async Task Migration_RollbackCleanly() + { + SkipIfNoPg(); + + await using (var db = CreateTestDb()) + { + var migrator = db.GetService(); + // Roll back to the migration immediately preceding AddGameCatalog. + await migrator.MigrateAsync("20260709094629_AddPeopleSearchAndSendCap"); + } + + await using var conn = new NpgsqlConnection(_testConn); + await conn.OpenAsync(); + foreach (var table in new[] + { "games", "game_tags", "game_mode_capabilities", "user_favorite_games", "catalog_seed_history" }) + { + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM information_schema.tables WHERE table_name = @t"; + cmd.Parameters.AddWithValue("t", table); + Assert.Equal(0L, (long)(await cmd.ExecuteScalarAsync())!); + } + + await using var indexCmd = conn.CreateCommand(); + indexCmd.CommandText = "SELECT COUNT(*) FROM pg_indexes WHERE indexname = @i"; + indexCmd.Parameters.AddWithValue("i", "ux_games_featured_rank_one"); + Assert.Equal(0L, (long)(await indexCmd.ExecuteScalarAsync())!); + } + +} diff --git a/tests/SimPle.IntegrationTests/Games/GameCatalogSeederTests.cs b/tests/SimPle.IntegrationTests/Games/GameCatalogSeederTests.cs new file mode 100644 index 0000000..04b45c3 --- /dev/null +++ b/tests/SimPle.IntegrationTests/Games/GameCatalogSeederTests.cs @@ -0,0 +1,181 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; +using SimPle.Domain.Games; +using SimPle.Infrastructure.Games; +using SimPle.Infrastructure.Persistence; +using Xunit; + +namespace SimPle.IntegrationTests.Games; + +/// +/// PostgreSQL-only verification of : clean apply, rerun idempotency (via +/// CatalogSeedHistory), fail-closed checksum mismatch, and advisory-lock convergence under concurrent runs. +/// Skipped unless MIGRATION_TEST_CONNECTION_STRING is set. Each test METHOD gets its own isolated database. +/// +public sealed class GameCatalogSeederTests : IAsyncLifetime +{ + private readonly string? _masterConn = Environment.GetEnvironmentVariable("MIGRATION_TEST_CONNECTION_STRING"); + private readonly string _dbName = $"simple_games_seed_{Guid.NewGuid():N}"; + private string? _testConn; + + public async Task InitializeAsync() + { + if (_masterConn is null) return; + + var builder = new NpgsqlConnectionStringBuilder(_masterConn) { Database = _dbName }; + _testConn = builder.ToString(); + + await using var masterConn = new NpgsqlConnection(_masterConn); + await masterConn.OpenAsync(); + await using var createCmd = masterConn.CreateCommand(); + createCmd.CommandText = $"CREATE DATABASE \"{_dbName}\""; + await createCmd.ExecuteNonQueryAsync(); + + await using var db = CreateTestDb(); + await db.Database.MigrateAsync(); + } + + public async Task DisposeAsync() + { + if (_masterConn is null || _testConn is null) return; + + await using var masterConn = new NpgsqlConnection(_masterConn); + await masterConn.OpenAsync(); + + await using var terminateCmd = masterConn.CreateCommand(); + terminateCmd.CommandText = $@" + SELECT pg_terminate_backend(pg_stat_activity.pid) + FROM pg_stat_activity + WHERE pg_stat_activity.datname = '{_dbName}' + AND pid <> pg_backend_pid();"; + await terminateCmd.ExecuteNonQueryAsync(); + + await using var dropCmd = masterConn.CreateCommand(); + dropCmd.CommandText = $"DROP DATABASE IF EXISTS \"{_dbName}\""; + await dropCmd.ExecuteNonQueryAsync(); + } + + private void SkipIfNoPg() => Skip.If(_masterConn is null, + "Set MIGRATION_TEST_CONNECTION_STRING to a PostgreSQL connection string to run these tests."); + + private AppDbContext CreateTestDb() => + new(new DbContextOptionsBuilder().UseNpgsql(_testConn).Options); + + // ── Clean apply ────────────────────────────────────────────────────────── + + [SkippableFact] + public async Task SeedAsync_CleanApply_Creates8Games() + { + SkipIfNoPg(); + + await using var db = CreateTestDb(); + var seeder = new GameCatalogSeeder(db, NullLogger.Instance); + + var result = await seeder.SeedAsync(); + + Assert.True(result.Success, result.Message); + Assert.Equal(8, result.GamesCreated); + Assert.Equal(0, result.GamesUpdated); + + await using var verify = CreateTestDb(); + Assert.Equal(8, await verify.Games.CountAsync()); + } + + // ── Rerun idempotency ──────────────────────────────────────────────────── + + [SkippableFact] + public async Task SeedAsync_Rerun_IsNoOp_NoDuplicateHistoryRow() + { + SkipIfNoPg(); + + await using (var db1 = CreateTestDb()) + { + var seeder1 = new GameCatalogSeeder(db1, NullLogger.Instance); + var first = await seeder1.SeedAsync(); + Assert.True(first.Success, first.Message); + } + + await using (var db2 = CreateTestDb()) + { + var seeder2 = new GameCatalogSeeder(db2, NullLogger.Instance); + var second = await seeder2.SeedAsync(); + Assert.True(second.Success, second.Message); + Assert.Equal(0, second.GamesCreated); + Assert.Equal(0, second.GamesUpdated); + } + + await using var verify = CreateTestDb(); + Assert.Equal(1, await verify.Set().CountAsync()); + Assert.Equal(8, await verify.Games.CountAsync()); + } + + // ── Checksum mismatch fails closed ────────────────────────────────────── + + [SkippableFact] + public async Task SeedAsync_ForgedHistoryWithWrongChecksum_FailsClosed_NoWrites() + { + SkipIfNoPg(); + + await using (var db = CreateTestDb()) + { + db.Set().Add(CatalogSeedHistory.Record("2026.1", new string('0', 64))); + await db.SaveChangesAsync(); + } + + await using (var db = CreateTestDb()) + { + var seeder = new GameCatalogSeeder(db, NullLogger.Instance); + var result = await seeder.SeedAsync(); + + Assert.False(result.Success); + Assert.Equal(0, result.GamesCreated); + Assert.Equal(0, result.GamesUpdated); + } + + await using var verify = CreateTestDb(); + Assert.Equal(0, await verify.Games.CountAsync()); + Assert.Equal(1, await verify.Set().CountAsync()); + } + + // ── Advisory lock convergence under concurrency ───────────────────────── + + [SkippableFact] + public async Task SeedAsync_TwoConcurrentSeeders_ConvergeToExactlyOneApply() + { + SkipIfNoPg(); + + await using var db1 = CreateTestDb(); + await using var db2 = CreateTestDb(); + var seeder1 = new GameCatalogSeeder(db1, NullLogger.Instance); + var seeder2 = new GameCatalogSeeder(db2, NullLogger.Instance); + + var results = await Task.WhenAll(seeder1.SeedAsync(), seeder2.SeedAsync()); + + Assert.All(results, r => Assert.True(r.Success, r.Message)); + + await using var verify = CreateTestDb(); + Assert.Equal(8, await verify.Games.CountAsync()); + Assert.Equal(1, await verify.Set().CountAsync()); + } + + // ── Featured rank ──────────────────────────────────────────────────────── + + [SkippableFact] + public async Task SeedAsync_OnlyChessLite_HasFeaturedRankOne() + { + SkipIfNoPg(); + + await using var db = CreateTestDb(); + var seeder = new GameCatalogSeeder(db, NullLogger.Instance); + await seeder.SeedAsync(); + + await using var verify = CreateTestDb(); + var featured = await verify.Games.Where(g => g.FeaturedRank == 1).ToListAsync(); + Assert.Single(featured); + Assert.Equal("chess-lite", featured[0].Slug); + + var others = await verify.Games.Where(g => g.Slug != "chess-lite").ToListAsync(); + Assert.All(others, g => Assert.Null(g.FeaturedRank)); + } +} diff --git a/tests/SimPle.IntegrationTests/Games/GamesEndpointsTests.cs b/tests/SimPle.IntegrationTests/Games/GamesEndpointsTests.cs new file mode 100644 index 0000000..5f4a81b --- /dev/null +++ b/tests/SimPle.IntegrationTests/Games/GamesEndpointsTests.cs @@ -0,0 +1,434 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using SimPle.Application.Games.DTOs; +using SimPle.Domain.Games; +using SimPle.Infrastructure.Persistence; +using SimPle.IntegrationTests.Auth; + +namespace SimPle.IntegrationTests.Games; + +/// +/// HTTP-contract tests for the Module 4 catalog/favorites endpoints (InMemory-backed +/// ): anonymous public reads, 404/410 lifecycle semantics, ETag/304/ +/// Vary caching, favorite idempotency/ownership/CSRF, and rate limits. Games have no create-via-API path (they +/// are seeded by GameCatalogSeeder/an admin CLI, not user input), so tests seed rows directly through the +/// DI-registered . Real-PostgreSQL-only concerns (xmin concurrency, 23505 favorite +/// races, EXPLAIN/index usage, LIKE-wildcard escaping) live in GamesPostgresConcurrencyTests instead. +/// +public sealed class GamesEndpointsTests : IDisposable +{ + private readonly TestWebApplicationFactory _factory = new(); + + // ── Anonymous public reads ────────────────────────────────────────────── + + [Fact] + public async Task List_Anonymous_Returns200WithPublicLifecyclesOnly() + { + using var client = CreateClient(); + await SeedGameAsync("chess-lite", GameLifecycle.Available); + await SeedGameAsync("hidden-draft", GameLifecycle.Draft); + + var response = await client.GetAsync("/api/games"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var body = await response.Content.ReadAsStringAsync(); + body.Should().Contain("chess-lite"); + body.Should().NotContain("hidden-draft"); + } + + [Fact] + public async Task List_Anonymous_HasPublicCacheHeadersAndETag() + { + using var client = CreateClient(); + await SeedGameAsync("chess-lite", GameLifecycle.Available); + + var response = await client.GetAsync("/api/games"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Headers.ETag.Should().NotBeNull(); + response.Headers.CacheControl!.ToString().Should().Contain("public"); + response.Headers.Vary.Should().Contain("Cookie"); + } + + [Fact] + public async Task GetBySlug_Anonymous_Available_Returns200() + { + using var client = CreateClient(); + await SeedGameAsync("chess-lite", GameLifecycle.Available); + + var response = await client.GetAsync("/api/games/chess-lite"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task GetBySlug_UnknownSlug_Returns404() + { + using var client = CreateClient(); + + var response = await client.GetAsync("/api/games/no-such-game"); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + var body = await response.Content.ReadAsStringAsync(); + body.Should().Contain("Games.NotFound"); + } + + [Fact] + public async Task GetBySlug_DraftGame_Returns404_IndistinguishableFromUnknown() + { + using var client = CreateClient(); + await SeedGameAsync("draft-game", GameLifecycle.Draft); + + var response = await client.GetAsync("/api/games/draft-game"); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task GetBySlug_RetiredGame_Returns410WithTombstoneDto_NoETagOrCacheHeaders() + { + using var client = CreateClient(); + await SeedGameAsync("retired-game", GameLifecycle.Retired); + + var response = await client.GetAsync("/api/games/retired-game"); + + response.StatusCode.Should().Be((HttpStatusCode)410); + var body = await response.Content.ReadAsStringAsync(); + body.Should().Contain("retired-game"); + body.Should().Contain("Games.Retired"); + response.Headers.ETag.Should().BeNull(); + response.Headers.CacheControl.Should().BeNull(); + } + + [Fact] + public async Task GetFeatured_NoneFeatured_Returns204() + { + using var client = CreateClient(); + await SeedGameAsync("chess-lite", GameLifecycle.Available); + + var response = await client.GetAsync("/api/games/featured"); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task GetFeatured_OneFeatured_Returns200WithETag() + { + using var client = CreateClient(); + await SeedGameAsync("chess-lite", GameLifecycle.Available, featuredRank: 1); + + var response = await client.GetAsync("/api/games/featured"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Headers.ETag.Should().NotBeNull(); + } + + // ── ETag / 304 ─────────────────────────────────────────────────────────── + + [Fact] + public async Task GetBySlug_IfNoneMatchWithCurrentETag_Returns304() + { + using var client = CreateClient(); + await SeedGameAsync("chess-lite", GameLifecycle.Available); + + var first = await client.GetAsync("/api/games/chess-lite"); + var etag = first.Headers.ETag!.Tag; + + var request = new HttpRequestMessage(HttpMethod.Get, "/api/games/chess-lite"); + request.Headers.TryAddWithoutValidation("If-None-Match", etag); + var second = await client.SendAsync(request); + + second.StatusCode.Should().Be(HttpStatusCode.NotModified); + } + + [Fact] + public async Task GetBySlug_IfNoneMatchWithStaleETag_Returns200() + { + using var client = CreateClient(); + await SeedGameAsync("chess-lite", GameLifecycle.Available); + + var request = new HttpRequestMessage(HttpMethod.Get, "/api/games/chess-lite"); + request.Headers.TryAddWithoutValidation("If-None-Match", "\"stale-etag-value\""); + var response = await client.SendAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + // ── Search / filter / cursor validation ────────────────────────────────── + + [Fact] + public async Task List_SearchTooShort_Returns400ValidationFailed() + { + using var client = CreateClient(); + + var response = await client.GetAsync("/api/games?query=a"); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + var body = await response.Content.ReadAsStringAsync(); + body.Should().Contain("Validation.Failed"); + } + + [Fact] + public async Task List_UnknownCategoryFilter_Returns400ValidationFailed() + { + using var client = CreateClient(); + + var response = await client.GetAsync("/api/games?category=not-a-real-category"); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task List_InvalidCursor_Returns400InvalidCursor() + { + using var client = CreateClient(); + + var response = await client.GetAsync("/api/games?after=@@not-a-cursor@@"); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + var body = await response.Content.ReadAsStringAsync(); + body.Should().Contain("Pagination.InvalidCursor"); + } + + [Fact] + public async Task List_PageSizeOverMax_Returns400ValidationFailed() + { + using var client = CreateClient(); + + var response = await client.GetAsync("/api/games?limit=51"); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + // ── Favorites: auth + CSRF guards ──────────────────────────────────────── + + [Fact] + public async Task GetFavorites_Unauthenticated_Returns401() + { + using var client = CreateClient(); + + var response = await client.GetAsync("/api/games/me/favorites"); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task PutFavorite_Unauthenticated_Returns401() + { + using var client = CreateClient(); + + var response = await client.PutAsync("/api/games/me/favorites/chess-lite", null); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task PutFavorite_MissingCsrfHeader_Returns400() + { + using var client = CreateClient(); + var (email, username) = UniqueUser(); + await RegisterAndLoginAsync(client, email, username); + await SeedGameAsync("chess-lite", GameLifecycle.Available); + client.DefaultRequestHeaders.Remove("X-Requested-With"); + + var response = await client.PutAsync("/api/games/me/favorites/chess-lite", null); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + var body = await response.Content.ReadAsStringAsync(); + body.Should().Contain("Auth.CsrfHeaderRequired"); + } + + [Fact] + public async Task DeleteFavorite_MissingCsrfHeader_Returns400() + { + using var client = CreateClient(); + var (email, username) = UniqueUser(); + await RegisterAndLoginAsync(client, email, username); + await SeedGameAsync("chess-lite", GameLifecycle.Available); + client.DefaultRequestHeaders.Remove("X-Requested-With"); + + var response = await client.DeleteAsync("/api/games/me/favorites/chess-lite"); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + // ── Favorites: happy path + idempotency + ownership ────────────────────── + + [Fact] + public async Task PutFavorite_UnknownSlug_Returns404() + { + using var client = CreateClient(); + var (email, username) = UniqueUser(); + await RegisterAndLoginAsync(client, email, username); + + var response = await client.PutAsync("/api/games/me/favorites/no-such-game", null); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task PutFavorite_NewFavorite_Returns200_NeverCached() + { + using var client = CreateClient(); + var (email, username) = UniqueUser(); + await RegisterAndLoginAsync(client, email, username); + await SeedGameAsync("chess-lite", GameLifecycle.Available); + + var response = await client.PutAsync("/api/games/me/favorites/chess-lite", null); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Headers.CacheControl!.ToString().Should().Contain("no-store"); + var body = await response.Content.ReadAsStringAsync(); + body.Should().Contain("chess-lite"); + } + + [Fact] + public async Task PutFavorite_CalledTwice_IsIdempotent_SameDto() + { + using var client = CreateClient(); + var (email, username) = UniqueUser(); + await RegisterAndLoginAsync(client, email, username); + await SeedGameAsync("chess-lite", GameLifecycle.Available); + + var first = await client.PutAsync("/api/games/me/favorites/chess-lite", null); + var second = await client.PutAsync("/api/games/me/favorites/chess-lite", null); + + first.StatusCode.Should().Be(HttpStatusCode.OK); + second.StatusCode.Should().Be(HttpStatusCode.OK); + (await first.Content.ReadAsStringAsync()).Should().Be(await second.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task PutFavorite_RetiredGame_Returns409GamesRetired() + { + using var client = CreateClient(); + var (email, username) = UniqueUser(); + await RegisterAndLoginAsync(client, email, username); + await SeedGameAsync("retired-game", GameLifecycle.Retired); + + var response = await client.PutAsync("/api/games/me/favorites/retired-game", null); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + var body = await response.Content.ReadAsStringAsync(); + body.Should().Contain("Games.Retired"); + } + + [Fact] + public async Task DeleteFavorite_ThenGetFavorites_NoLongerListed() + { + using var client = CreateClient(); + var (email, username) = UniqueUser(); + await RegisterAndLoginAsync(client, email, username); + await SeedGameAsync("chess-lite", GameLifecycle.Available); + + await client.PutAsync("/api/games/me/favorites/chess-lite", null); + var deleteResponse = await client.DeleteAsync("/api/games/me/favorites/chess-lite"); + var listResponse = await client.GetAsync("/api/games/me/favorites"); + + deleteResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); + var body = await listResponse.Content.ReadAsStringAsync(); + body.Should().NotContain("chess-lite"); + } + + [Fact] + public async Task DeleteFavorite_NeverFavorited_IsIdempotent_Returns204() + { + using var client = CreateClient(); + var (email, username) = UniqueUser(); + await RegisterAndLoginAsync(client, email, username); + await SeedGameAsync("chess-lite", GameLifecycle.Available); + + var response = await client.DeleteAsync("/api/games/me/favorites/chess-lite"); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task GetFavorites_OnlyShowsCallersOwnFavorites_NotAnotherUsersFavorite() + { + using var owner = CreateClient(); + var (ownerEmail, ownerUsername) = UniqueUser(); + await RegisterAndLoginAsync(owner, ownerEmail, ownerUsername); + await SeedGameAsync("chess-lite", GameLifecycle.Available); + await owner.PutAsync("/api/games/me/favorites/chess-lite", null); + + using var stranger = CreateClient(); + var (strangerEmail, strangerUsername) = UniqueUser(); + await RegisterAndLoginAsync(stranger, strangerEmail, strangerUsername); + + var response = await stranger.GetAsync("/api/games/me/favorites"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var body = await response.Content.ReadAsStringAsync(); + body.Should().NotContain("chess-lite"); + } + + // ── Rate limits ─────────────────────────────────────────────────────────── + + [Fact] + public async Task List_CatalogSearchRateLimit_ReturnsTooManyRequestsAfterLimit() + { + // catalog-search GlobalLimiter branch: 30/min/IP, only when `query` is non-empty (Program.cs). + using var client = CreateClient(); + + HttpResponseMessage? response = null; + for (var i = 0; i < 31; i++) + response = await client.GetAsync("/api/games?query=chess"); + + response!.StatusCode.Should().Be(HttpStatusCode.TooManyRequests); + (await response.Content.ReadAsStringAsync()).Should().Contain("RateLimit.Exceeded"); + response.Headers.Contains("Retry-After").Should().BeTrue(); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private const string TestPassword = "ValidPassword1"; + + private static (string email, string username) UniqueUser() + { + var suffix = Guid.NewGuid().ToString("N")[..8]; + return ($"gme-{suffix}@example.com", $"gme{suffix}"); + } + + private HttpClient CreateClient() => + _factory.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + HandleCookies = true + }).WithCsrfHeader(); + + private static async Task RegisterAndLoginAsync(HttpClient client, string email, string username) + { + await client.PostAsJsonAsync("/api/auth/register", new + { + Username = username, + Email = email, + Password = TestPassword, + ConfirmPassword = TestPassword, + CaptchaToken = "test-captcha-token" + }); + await client.PostAsJsonAsync("/api/auth/login", new + { + EmailOrUsername = email, + Password = TestPassword, + CaptchaToken = "test-captcha-token" + }); + } + + private async Task SeedGameAsync(string slug, GameLifecycle lifecycle, int? featuredRank = null) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Games.Add(Game.Create( + slug, "Test Game " + slug, "A test game summary.", "Test rules summary.", GameDifficulty.Medium, + 5, 25, 2, 2, lifecycle, featuredRank, 0, + "art-token", "#111111", "#222222", "Test game abstract artwork", "2026.1", + "strategy", Array.Empty(), Array.Empty())); + await db.SaveChangesAsync(); + } + + public void Dispose() => _factory.Dispose(); +} diff --git a/tests/SimPle.IntegrationTests/Games/GamesPostgresConcurrencyTests.cs b/tests/SimPle.IntegrationTests/Games/GamesPostgresConcurrencyTests.cs new file mode 100644 index 0000000..680cdfd --- /dev/null +++ b/tests/SimPle.IntegrationTests/Games/GamesPostgresConcurrencyTests.cs @@ -0,0 +1,345 @@ +using Microsoft.EntityFrameworkCore; +using Npgsql; +using SimPle.Application.Common.Interfaces; +using SimPle.Application.Games; +using SimPle.Application.Games.Outbox; +using SimPle.Domain.Games; +using SimPle.Domain.Users; +using SimPle.Infrastructure.Persistence; +using SimPle.Infrastructure.Persistence.Repositories; +using Xunit; + +namespace SimPle.IntegrationTests.Games; + +/// +/// PostgreSQL-only verification of the Module 4 concurrency and query-plan invariants that EF InMemory cannot +/// prove: favorite-row unique-pair convergence, outbox-driven refavorite/unfavorite race convergence, +/// keyset-index usage via EXPLAIN for the sorts whose ORDER BY matches a raw indexed column, and LIKE-wildcard +/// escaping for the free-text search filter (brief Risk #3). +/// +/// Skipped unless MIGRATION_TEST_CONNECTION_STRING is set to a running PostgreSQL instance. Each test METHOD +/// gets its own isolated database (xUnit IAsyncLifetime), created in InitializeAsync and dropped in DisposeAsync. +/// +public sealed class GamesPostgresConcurrencyTests : IAsyncLifetime +{ + private readonly string? _masterConn = Environment.GetEnvironmentVariable("MIGRATION_TEST_CONNECTION_STRING"); + private readonly string _dbName = $"simple_games_pgc_{Guid.NewGuid():N}"; + private string? _testConn; + + public async Task InitializeAsync() + { + if (_masterConn is null) return; + + var builder = new NpgsqlConnectionStringBuilder(_masterConn) { Database = _dbName }; + _testConn = builder.ToString(); + + await using var masterConn = new NpgsqlConnection(_masterConn); + await masterConn.OpenAsync(); + await using var createCmd = masterConn.CreateCommand(); + createCmd.CommandText = $"CREATE DATABASE \"{_dbName}\""; + await createCmd.ExecuteNonQueryAsync(); + + await using var db = CreateTestDb(); + await db.Database.MigrateAsync(); + } + + public async Task DisposeAsync() + { + if (_masterConn is null || _testConn is null) return; + + await using var masterConn = new NpgsqlConnection(_masterConn); + await masterConn.OpenAsync(); + + await using var terminateCmd = masterConn.CreateCommand(); + terminateCmd.CommandText = $@" + SELECT pg_terminate_backend(pg_stat_activity.pid) + FROM pg_stat_activity + WHERE pg_stat_activity.datname = '{_dbName}' + AND pid <> pg_backend_pid();"; + await terminateCmd.ExecuteNonQueryAsync(); + + await using var dropCmd = masterConn.CreateCommand(); + dropCmd.CommandText = $"DROP DATABASE IF EXISTS \"{_dbName}\""; + await dropCmd.ExecuteNonQueryAsync(); + } + + private void SkipIfNoPg() => Skip.If(_masterConn is null, + "Set MIGRATION_TEST_CONNECTION_STRING to a PostgreSQL connection string to run these tests."); + + private AppDbContext CreateTestDb() => + new(new DbContextOptionsBuilder().UseNpgsql(_testConn).Options); + + private async Task SeedUserAsync() + { + await using var db = CreateTestDb(); + var g = Guid.NewGuid(); + var user = User.Create($"gpgc{g:N}"[..24], $"gpgc{g:N}@test.io", "hash", "PG Games User"); + db.Users.Add(user); + await db.SaveChangesAsync(); + return user; + } + + private async Task SeedGameAsync( + string slug, GameLifecycle lifecycle = GameLifecycle.Available, string name = "Test Game", + GameDifficulty difficulty = GameDifficulty.Medium, int durationMin = 5, int sortOrder = 0, + int? featuredRank = null, string category = "strategy", string[]? tags = null) + { + await using var db = CreateTestDb(); + var game = Game.Create( + slug, name, "Summary.", "Rules.", difficulty, durationMin, durationMin + 20, 1, 2, + lifecycle, featuredRank, sortOrder, "art-token", "#111111", "#222222", + $"{name} abstract game artwork", "2026.1", category, tags ?? Array.Empty(), Array.Empty()); + db.Games.Add(game); + await db.SaveChangesAsync(); + return game; + } + + // ── Favorite unique-pair convergence (23505) ──────────────────────────── + + [SkippableFact] + public async Task AddFavorite_ConcurrentInsertSamePair_ExactlyOneWins_23505Converges() + { + SkipIfNoPg(); + var user = await SeedUserAsync(); + var game = await SeedGameAsync("race-game"); + + await using var ctx1 = CreateTestDb(); + await using var ctx2 = CreateTestDb(); + var repo1 = new GameRepository(ctx1); + var repo2 = new GameRepository(ctx2); + + var fav1 = UserFavoriteGame.Favorite(user.Id, game.Id); + var fav2 = UserFavoriteGame.Favorite(user.Id, game.Id); + + var r1 = await repo1.AddFavoriteAsync(fav1, GameOutbox.GameFavoritedEvent(fav1)); + var r2 = await repo2.AddFavoriteAsync(fav2, GameOutbox.GameFavoritedEvent(fav2)); + + var outcomes = new[] { r1, r2 }; + Assert.Single(outcomes, o => o == AddFavoriteOutcome.Added); + Assert.Single(outcomes, o => o == AddFavoriteOutcome.Conflict); + + await using var verify = CreateTestDb(); + Assert.Equal(1, await verify.Set().CountAsync(f => f.UserId == user.Id && f.GameId == game.Id)); + } + + [SkippableFact] + public async Task AddFavorite_StagesOutboxAtomically_OnSuccess() + { + SkipIfNoPg(); + var user = await SeedUserAsync(); + var game = await SeedGameAsync("atomic-fav-game"); + + await using var ctx = CreateTestDb(); + var repo = new GameRepository(ctx); + var favorite = UserFavoriteGame.Favorite(user.Id, game.Id); + + var outcome = await repo.AddFavoriteAsync(favorite, GameOutbox.GameFavoritedEvent(favorite)); + Assert.Equal(AddFavoriteOutcome.Added, outcome); + + await using var verify = CreateTestDb(); + Assert.Equal(1, await verify.Set().CountAsync(f => f.Id == favorite.Id)); + Assert.Equal(1, await verify.OutboxMessages.CountAsync(m => + m.AggregateId == favorite.Id && m.EventType == GameOutbox.GameFavorited)); + } + + // ── Refavorite race: outbox (AggregateId, EventType, AggregateDomainVersion) uniqueness is the only + // conflict signal since UserFavoriteGame carries no xmin/row-version token of its own (documented gap) ── + + [SkippableFact] + public async Task RefavoriteRace_TwoContextsReactivateSameInactiveRow_OneWinsOneConverges() + { + SkipIfNoPg(); + var user = await SeedUserAsync(); + var game = await SeedGameAsync("refav-race-game"); + + await using (var seed = CreateTestDb()) + { + var f = UserFavoriteGame.Favorite(user.Id, game.Id); + f.Unfavorite(); + seed.Add(f); + await seed.SaveChangesAsync(); + } + + await using var ctx1 = CreateTestDb(); + await using var ctx2 = CreateTestDb(); + var repo1 = new GameRepository(ctx1); + var repo2 = new GameRepository(ctx2); + + var row1 = await repo1.GetFavoriteAsync(user.Id, game.Id); + var row2 = await repo2.GetFavoriteAsync(user.Id, game.Id); + row1!.Refavorite(); + row2!.Refavorite(); + + var r1 = await repo1.UpdateFavoriteAsync(row1, GameOutbox.GameFavoritedEvent(row1)); + var r2 = await repo2.UpdateFavoriteAsync(row2, GameOutbox.GameFavoritedEvent(row2)); + + var outcomes = new[] { r1, r2 }; + Assert.Single(outcomes, o => o == UpdateFavoriteOutcome.Updated); + Assert.Single(outcomes, o => o == UpdateFavoriteOutcome.ConcurrencyConflict); + + await using var verify = CreateTestDb(); + var persisted = await verify.Set().SingleAsync(f => f.UserId == user.Id && f.GameId == game.Id); + Assert.True(persisted.IsActive); + } + + // ── EXPLAIN: default-sort keyset query uses the raw covering index (no transform in the ORDER BY) ── + + [SkippableFact] + public async Task Explain_DefaultSortKeyset_UsesDefaultOrderIndex() + { + SkipIfNoPg(); + for (var i = 0; i < 6; i++) + await SeedGameAsync($"default-sort-{i}", sortOrder: i); + + await using var conn = new NpgsqlConnection(_testConn); + await conn.OpenAsync(); + await using (var setCmd = conn.CreateCommand()) + { + setCmd.CommandText = "SET enable_seqscan = off;"; + await setCmd.ExecuteNonQueryAsync(); + } + + // Mirrors GameRepository.GetCatalogPageAsync's default-sort ORDER BY (FeaturedRank, SortOrder, Slug) — + // no case conversion or rank transform, so the raw composite index directly satisfies the sort. + await using var explain = conn.CreateCommand(); + explain.CommandText = """ + EXPLAIN (FORMAT TEXT) + SELECT "Id", "Slug", "FeaturedRank", "SortOrder" + FROM games + WHERE "Lifecycle" IN ('ComingSoon', 'Available', 'Maintenance') + ORDER BY "FeaturedRank", "SortOrder", "Slug" + LIMIT 24; + """; + + var plan = new System.Text.StringBuilder(); + await using (var reader = await explain.ExecuteReaderAsync()) + while (await reader.ReadAsync()) + plan.AppendLine(reader.GetString(0)); + + var planText = plan.ToString(); + Assert.Contains("ix_games_default_order", planText); + Assert.DoesNotContain("Seq Scan", planText); + } + + // ── EXPLAIN: duration-sort keyset query uses the raw covering index ── + + [SkippableFact] + public async Task Explain_DurationSortKeyset_UsesDurationIndex() + { + SkipIfNoPg(); + for (var i = 0; i < 6; i++) + await SeedGameAsync($"duration-sort-{i}", durationMin: i + 1); + + await using var conn = new NpgsqlConnection(_testConn); + await conn.OpenAsync(); + await using (var setCmd = conn.CreateCommand()) + { + setCmd.CommandText = "SET enable_seqscan = off;"; + await setCmd.ExecuteNonQueryAsync(); + } + + await using var explain = conn.CreateCommand(); + explain.CommandText = """ + EXPLAIN (FORMAT TEXT) + SELECT "Id", "Slug", "EstimatedDurationMinMinutes" + FROM games + ORDER BY "EstimatedDurationMinMinutes", "Slug" + LIMIT 24; + """; + + var plan = new System.Text.StringBuilder(); + await using (var reader = await explain.ExecuteReaderAsync()) + while (await reader.ReadAsync()) + plan.AppendLine(reader.GetString(0)); + + var planText = plan.ToString(); + Assert.Contains("ix_games_duration_slug", planText); + Assert.DoesNotContain("Seq Scan", planText); + } + + // ── EXPLAIN (diagnostic, not a pass/fail claim): Difficulty sort orders by a CASE-derived rank, which the + // raw (Difficulty, Slug) index cannot satisfy directly. This documents the known gap rather than asserting + // an index is used — a future perf pass should either add an expression index or drop this caveat. + + [SkippableFact] + public async Task Explain_DifficultySortKeyset_CannotUseRawIndex_DocumentedGap() + { + SkipIfNoPg(); + for (var i = 0; i < 6; i++) + await SeedGameAsync($"difficulty-sort-{i}", difficulty: (GameDifficulty)(i % 3)); + + await using var conn = new NpgsqlConnection(_testConn); + await conn.OpenAsync(); + await using (var setCmd = conn.CreateCommand()) + { + setCmd.CommandText = "SET enable_seqscan = off;"; + await setCmd.ExecuteNonQueryAsync(); + } + + // Mirrors GameRepository's Difficulty-sort ORDER BY (CASE Easy=0/Hard=2/else=1), Slug. + await using var explain = conn.CreateCommand(); + explain.CommandText = """ + EXPLAIN (FORMAT TEXT) + SELECT "Id", "Slug", "Difficulty" + FROM games + ORDER BY (CASE WHEN "Difficulty" = 'Easy' THEN 0 WHEN "Difficulty" = 'Hard' THEN 2 ELSE 1 END), "Slug" + LIMIT 24; + """; + + var plan = new System.Text.StringBuilder(); + await using (var reader = await explain.ExecuteReaderAsync()) + while (await reader.ReadAsync()) + plan.AppendLine(reader.GetString(0)); + + // No assertion on index usage here by design — ix_games_difficulty_slug is on the raw enum-as-string + // column, not the CASE-derived rank, so it cannot satisfy this ORDER BY. Verified ground truth: even + // with enable_seqscan=off (cost +1e10) Postgres still chooses Seq Scan, confirming no index can serve + // this sort. This test exists to keep the gap visible (see the final evidence report's deviations). + Assert.NotEmpty(plan.ToString()); + } + + // ── LIKE-wildcard escaping: %, _, \ in the search value must not be treated as pattern metacharacters ── + + [SkippableFact] + public async Task Search_PercentWildcardInQuery_IsEscaped_MatchesOnlyLiteralSubstring() + { + SkipIfNoPg(); + // "100%" is a real name substring on one game; a naive unescaped LIKE '%100%%' would ALSO match + // any name containing "100" followed by arbitrary characters, i.e. everything — proving escaping + // requires a name that would spuriously match if % were treated as a wildcard instead of a literal. + await SeedGameAsync("percent-literal", name: "Wins 100% Guaranteed"); + await SeedGameAsync("percent-decoy", name: "Wins 100X Guaranteed"); + + await using var db = CreateTestDb(); + var repo = new GameRepository(db); + var filter = new GameCatalogFilter( + "100% GUARANTEED", Array.Empty(), Array.Empty(), Array.Empty(), + new[] { GameLifecycle.Available }, GameCatalogSortKey.Default, 10, null, null); + + var results = await repo.GetCatalogPageAsync(filter); + + Assert.Single(results, g => g.Slug == "percent-literal"); + Assert.DoesNotContain(results, g => g.Slug == "percent-decoy"); + } + + [SkippableFact] + public async Task Search_UnderscoreWildcardInQuery_IsEscaped_MatchesOnlyLiteralSubstring() + { + SkipIfNoPg(); + // "_" would match any single character under a naive LIKE; "co_op" only matches the literal decoy + // if the underscore were left unescaped ("coXop" would spuriously match too). + await SeedGameAsync("underscore-literal", name: "Co_Op Puzzle"); + await SeedGameAsync("underscore-decoy", name: "CoXOp Puzzle"); + + await using var db = CreateTestDb(); + var repo = new GameRepository(db); + var filter = new GameCatalogFilter( + "CO_OP PUZZLE", Array.Empty(), Array.Empty(), Array.Empty(), + new[] { GameLifecycle.Available }, GameCatalogSortKey.Default, 10, null, null); + + var results = await repo.GetCatalogPageAsync(filter); + + Assert.Single(results, g => g.Slug == "underscore-literal"); + Assert.DoesNotContain(results, g => g.Slug == "underscore-decoy"); + } +} diff --git a/tests/SimPle.UnitTests/Games/GameCatalogCursorTests.cs b/tests/SimPle.UnitTests/Games/GameCatalogCursorTests.cs new file mode 100644 index 0000000..783b357 --- /dev/null +++ b/tests/SimPle.UnitTests/Games/GameCatalogCursorTests.cs @@ -0,0 +1,154 @@ +using FluentAssertions; +using SimPle.Application.Common.Pagination; +using SimPle.Application.Games; +using SimPle.Domain.Games; + +namespace SimPle.UnitTests.Games; + +/// +/// Cursor.EncodeCatalog/TryDecodeCatalog round-trip and failure modes, plus GameCatalogSortKey's per-sort +/// encode/decode stability. No repository/service involved — see GamesServiceTests for the service-level +/// cursor validation (shape-hash binding, sort-key decodability against the active sort). +/// +public sealed class GameCatalogCursorTests +{ + private static Game MakeGame( + string slug = "chess-lite", int? featuredRank = 1, int sortOrder = 3, + GameDifficulty difficulty = GameDifficulty.Hard, int durationMin = 5) => Game.Create( + slug: slug, name: "Chess Lite", summary: "A streamlined chess experience.", + rulesSummary: "Chess Lite wins by checkmate.", difficulty: difficulty, + estimatedDurationMinMinutes: durationMin, estimatedDurationMaxMinutes: durationMin + 20, + minPlayers: 2, maxPlayers: 2, initialLifecycle: GameLifecycle.ComingSoon, + featuredRank: featuredRank, sortOrder: sortOrder, artToken: "chess-lite", + artColorA: "#9B51E0", artColorB: "#2D9CDB", artAltText: "Chess Lite abstract game artwork", + manifestVersion: "2026.1", category: "strategy", tags: Array.Empty(), + modes: Array.Empty()); + + // ── Cursor round-trip ──────────────────────────────────────────────────── + + [Fact] + public void EncodeCatalog_TryDecodeCatalog_RoundTrips() + { + var cursor = Cursor.EncodeCatalog("0000000001", "chess-lite", "abc123hash"); + + var ok = Cursor.TryDecodeCatalog(cursor, out var sortKey, out var slug, out var shapeHash); + + ok.Should().BeTrue(); + sortKey.Should().Be("0000000001"); + slug.Should().Be("chess-lite"); + shapeHash.Should().Be("abc123hash"); + } + + [Fact] + public void TryDecodeCatalog_Null_ReturnsFalse() + { + Cursor.TryDecodeCatalog(null, out _, out _, out _).Should().BeFalse(); + } + + [Fact] + public void TryDecodeCatalog_MalformedBase64_ReturnsFalse() + { + Cursor.TryDecodeCatalog("not-valid-base64!!!", out _, out _, out _).Should().BeFalse(); + } + + [Fact] + public void TryDecodeCatalog_ForgedToken_FromADifferentCursorShape_ReturnsFalse() + { + // A cursor built by a different Encode* helper has the wrong segment count and must not decode. + var foreignCursor = Cursor.EncodeStringId("someKey", Guid.NewGuid()); + + Cursor.TryDecodeCatalog(foreignCursor, out _, out _, out _).Should().BeFalse(); + } + + // ── GameCatalogSortKey: encode/decode stability per sort ──────────────── + + [Fact] + public void Encode_Default_IsFixedWidthAndOrdersRankThenSortOrder() + { + var ranked = MakeGame(featuredRank: 1, sortOrder: 3); + var unranked = MakeGame(featuredRank: null, sortOrder: 0); + + var rankedKey = GameCatalogSortKey.Encode(GameCatalogSortKey.Default, ranked); + var unrankedKey = GameCatalogSortKey.Encode(GameCatalogSortKey.Default, unranked); + + rankedKey.Length.Should().Be(20); + unrankedKey.Length.Should().Be(20); + // Text ordering must mirror Postgres NULLS LAST: any real rank sorts before the null-tail sentinel. + string.CompareOrdinal(rankedKey, unrankedKey).Should().BeLessThan(0); + } + + [Fact] + public void Encode_Default_RoundTripsThroughTryDecodeDefault() + { + var game = MakeGame(featuredRank: 7, sortOrder: 42); + + var key = GameCatalogSortKey.Encode(GameCatalogSortKey.Default, game); + var ok = GameCatalogSortKey.TryDecodeDefault(key, out var rank, out var order); + + ok.Should().BeTrue(); + rank.Should().Be(7); + order.Should().Be(42); + } + + [Fact] + public void Encode_Default_NullFeaturedRank_DecodesToSentinel() + { + var game = MakeGame(featuredRank: null, sortOrder: 5); + + var key = GameCatalogSortKey.Encode(GameCatalogSortKey.Default, game); + GameCatalogSortKey.TryDecodeDefault(key, out var rank, out _).Should().BeTrue(); + + rank.Should().Be(GameCatalogSortKey.NoFeaturedRank); + } + + [Fact] + public void Encode_Name_UppercasesTheName() + { + var key = GameCatalogSortKey.Encode(GameCatalogSortKey.Name, MakeGame()); + key.Should().Be("CHESS LITE"); + } + + [Theory] + [InlineData(GameDifficulty.Easy, 0)] + [InlineData(GameDifficulty.Medium, 1)] + [InlineData(GameDifficulty.Hard, 2)] + public void DifficultyRank_OrdersEasyMediumHard_RegardlessOfStringStorage(GameDifficulty difficulty, int expectedRank) + { + GameCatalogSortKey.DifficultyRank(difficulty).Should().Be(expectedRank); + } + + [Fact] + public void Encode_Difficulty_RoundTripsThroughTryDecodeDifficulty() + { + var game = MakeGame(difficulty: GameDifficulty.Medium); + + var key = GameCatalogSortKey.Encode(GameCatalogSortKey.Difficulty, game); + GameCatalogSortKey.TryDecodeDifficulty(key, out var rank).Should().BeTrue(); + + rank.Should().Be(1); + } + + [Fact] + public void TryDecodeDifficulty_OutOfRange_ReturnsFalse() + { + GameCatalogSortKey.TryDecodeDifficulty("99", out _).Should().BeFalse(); + GameCatalogSortKey.TryDecodeDifficulty("not-a-number", out _).Should().BeFalse(); + } + + [Fact] + public void Encode_Duration_RoundTripsThroughTryDecodeDuration() + { + var game = MakeGame(durationMin: 15); + + var key = GameCatalogSortKey.Encode(GameCatalogSortKey.Duration, game); + GameCatalogSortKey.TryDecodeDuration(key, out var minutes).Should().BeTrue(); + + minutes.Should().Be(15); + } + + [Fact] + public void TryDecodeDuration_Negative_ReturnsFalse() + { + GameCatalogSortKey.TryDecodeDuration("-5", out _).Should().BeFalse(); + } +} diff --git a/tests/SimPle.UnitTests/Games/GameTests.cs b/tests/SimPle.UnitTests/Games/GameTests.cs new file mode 100644 index 0000000..02367e9 --- /dev/null +++ b/tests/SimPle.UnitTests/Games/GameTests.cs @@ -0,0 +1,354 @@ +using FluentAssertions; +using SimPle.Domain.Games; + +namespace SimPle.UnitTests.Games; + +/// +/// Pure in-memory domain tests for : lifecycle state machine edges, the Draft/Retired +/// cannot-be-featured guard, and the Create/ApplyManifestUpdate validation invariants. No database involved — +/// see tests/SimPle.IntegrationTests/Games for the real-Postgres CHECK/index verification. +/// +public sealed class GameTests +{ + private static Game ValidComingSoonGame(string slug = "chess-lite") => Game.Create( + slug: slug, + name: "Chess Lite", + summary: "A streamlined chess experience.", + rulesSummary: "Chess Lite wins by checkmate or a future configured clock.", + difficulty: GameDifficulty.Hard, + estimatedDurationMinMinutes: 5, + estimatedDurationMaxMinutes: 25, + minPlayers: 2, + maxPlayers: 2, + initialLifecycle: GameLifecycle.ComingSoon, + featuredRank: 1, + sortOrder: 3, + artToken: "chess-lite", + artColorA: "#9B51E0", + artColorB: "#2D9CDB", + artAltText: "Chess Lite abstract game artwork", + manifestVersion: "2026.1", + category: "strategy", + tags: Array.Empty(), + modes: new[] { "ai", "multiplayer", "ranked" }); + + // ── Lifecycle: legal edges ────────────────────────────────────────────── + + [Fact] + public void Publish_FromDraft_TransitionsToComingSoon() + { + var game = Game.Create( + "draft-game", "Draft Game", "Summary.", "Rules.", GameDifficulty.Easy, + 1, 5, 1, 2, GameLifecycle.Draft, null, 0, + "draft-game", "#111111", "#222222", "Draft Game abstract game artwork", "2026.1", + "puzzle", new[] { "logic" }, new[] { "solo" }); + + game.Publish(); + + game.Lifecycle.Should().Be(GameLifecycle.ComingSoon); + } + + [Fact] + public void MakeAvailable_FromComingSoon_TransitionsToAvailable() + { + var game = ValidComingSoonGame(); + + game.MakeAvailable(); + + game.Lifecycle.Should().Be(GameLifecycle.Available); + } + + [Fact] + public void EnterMaintenance_FromAvailable_TransitionsToMaintenance() + { + var game = ValidComingSoonGame(); + game.MakeAvailable(); + + game.EnterMaintenance(); + + game.Lifecycle.Should().Be(GameLifecycle.Maintenance); + } + + [Fact] + public void MakeAvailable_FromMaintenance_TransitionsToAvailable() + { + var game = ValidComingSoonGame(); + game.MakeAvailable(); + game.EnterMaintenance(); + + game.MakeAvailable(); + + game.Lifecycle.Should().Be(GameLifecycle.Available); + } + + [Theory] + [InlineData(GameLifecycle.Draft)] + [InlineData(GameLifecycle.ComingSoon)] + [InlineData(GameLifecycle.Available)] + [InlineData(GameLifecycle.Maintenance)] + public void Retire_FromAnyNonRetiredState_TransitionsToRetired(GameLifecycle from) + { + var game = GameAt(from); + + game.Retire(); + + game.Lifecycle.Should().Be(GameLifecycle.Retired); + } + + // ── Lifecycle: illegal edges throw ────────────────────────────────────── + + [Fact] + public void MakeAvailable_FromDraft_Throws() + { + var game = GameAt(GameLifecycle.Draft); + var act = () => game.MakeAvailable(); + act.Should().Throw(); + } + + [Fact] + public void EnterMaintenance_FromDraft_Throws() + { + var game = GameAt(GameLifecycle.Draft); + var act = () => game.EnterMaintenance(); + act.Should().Throw(); + } + + [Fact] + public void EnterMaintenance_FromComingSoon_Throws() + { + var game = GameAt(GameLifecycle.ComingSoon); + var act = () => game.EnterMaintenance(); + act.Should().Throw(); + } + + [Fact] + public void Publish_FromComingSoon_Throws() + { + var game = GameAt(GameLifecycle.ComingSoon); + var act = () => game.Publish(); + act.Should().Throw(); + } + + [Fact] + public void Publish_FromAvailable_Throws() + { + var game = GameAt(GameLifecycle.Available); + var act = () => game.Publish(); + act.Should().Throw(); + } + + [Fact] + public void EnterMaintenance_FromMaintenance_Throws() + { + var game = GameAt(GameLifecycle.Maintenance); + var act = () => game.EnterMaintenance(); + act.Should().Throw(); + } + + [Theory] + [InlineData(GameLifecycle.Draft)] + [InlineData(GameLifecycle.ComingSoon)] + [InlineData(GameLifecycle.Available)] + [InlineData(GameLifecycle.Maintenance)] + [InlineData(GameLifecycle.Retired)] + public void Retired_IsTerminal_NothingTransitionsOut(GameLifecycle _) + { + var game = GameAt(GameLifecycle.Retired); + + game.Invoking(g => g.Publish()).Should().Throw(); + game.Invoking(g => g.MakeAvailable()).Should().Throw(); + game.Invoking(g => g.EnterMaintenance()).Should().Throw(); + game.Invoking(g => g.Retire()).Should().Throw(); + } + + [Fact] + public void Retire_ClearsFeaturedRank() + { + var game = ValidComingSoonGame(); // seeded with FeaturedRank = 1 + game.FeaturedRank.Should().Be(1); + + game.Retire(); + + game.FeaturedRank.Should().BeNull(); + } + + [Fact] + public void TransitionTo_DispatchesToTheCorrectNamedMethod() + { + var game = GameAt(GameLifecycle.Draft); + + game.TransitionTo(GameLifecycle.ComingSoon); + game.Lifecycle.Should().Be(GameLifecycle.ComingSoon); + + game.TransitionTo(GameLifecycle.Available); + game.Lifecycle.Should().Be(GameLifecycle.Available); + + game.TransitionTo(GameLifecycle.Retired); + game.Lifecycle.Should().Be(GameLifecycle.Retired); + } + + private static Game GameAt(GameLifecycle target) + { + var game = Game.Create( + $"game-{target}".ToLowerInvariant(), "Game", "Summary.", "Rules.", GameDifficulty.Easy, + 1, 5, 1, 2, GameLifecycle.Draft, null, 0, + "art-token", "#111111", "#222222", "Game abstract game artwork", "2026.1", + "puzzle", new[] { "logic" }, new[] { "solo" }); + + switch (target) + { + case GameLifecycle.Draft: + return game; + case GameLifecycle.ComingSoon: + game.Publish(); + return game; + case GameLifecycle.Available: + game.Publish(); + game.MakeAvailable(); + return game; + case GameLifecycle.Maintenance: + game.Publish(); + game.MakeAvailable(); + game.EnterMaintenance(); + return game; + case GameLifecycle.Retired: + game.Retire(); + return game; + default: + throw new ArgumentOutOfRangeException(nameof(target)); + } + } + + // ── Create / ApplyManifestUpdate validation ───────────────────────────── + + [Fact] + public void Create_RejectsMinPlayersLessThanOne() + { + var act = () => Game.Create( + "slug", "Name", "Summary.", "Rules.", GameDifficulty.Easy, + 1, 5, 0, 2, GameLifecycle.ComingSoon, null, 0, + "art", "#111111", "#222222", "alt text", "2026.1", + "puzzle", new[] { "logic" }, new[] { "solo" }); + + act.Should().Throw(); + } + + [Fact] + public void Create_RejectsMinPlayersGreaterThanMaxPlayers() + { + var act = () => Game.Create( + "slug", "Name", "Summary.", "Rules.", GameDifficulty.Easy, + 1, 5, 3, 2, GameLifecycle.ComingSoon, null, 0, + "art", "#111111", "#222222", "alt text", "2026.1", + "puzzle", new[] { "logic" }, new[] { "solo" }); + + act.Should().Throw(); + } + + [Fact] + public void Create_RejectsDurationMinGreaterThanMax() + { + var act = () => Game.Create( + "slug", "Name", "Summary.", "Rules.", GameDifficulty.Easy, + 10, 5, 1, 2, GameLifecycle.ComingSoon, null, 0, + "art", "#111111", "#222222", "alt text", "2026.1", + "puzzle", new[] { "logic" }, new[] { "solo" }); + + act.Should().Throw(); + } + + [Fact] + public void Create_RejectsNonAllowListedTag() + { + var act = () => Game.Create( + "slug", "Name", "Summary.", "Rules.", GameDifficulty.Easy, + 1, 5, 1, 2, GameLifecycle.ComingSoon, null, 0, + "art", "#111111", "#222222", "alt text", "2026.1", + "puzzle", new[] { "not-a-real-tag" }, new[] { "solo" }); + + act.Should().Throw(); + } + + [Fact] + public void Create_RejectsNonAllowListedMode() + { + var act = () => Game.Create( + "slug", "Name", "Summary.", "Rules.", GameDifficulty.Easy, + 1, 5, 1, 2, GameLifecycle.ComingSoon, null, 0, + "art", "#111111", "#222222", "alt text", "2026.1", + "puzzle", new[] { "logic" }, new[] { "not-a-real-mode" }); + + act.Should().Throw(); + } + + [Fact] + public void Create_RejectsRankedWithoutMultiplayer() + { + var act = () => Game.Create( + "slug", "Name", "Summary.", "Rules.", GameDifficulty.Easy, + 1, 5, 2, 2, GameLifecycle.ComingSoon, null, 0, + "art", "#111111", "#222222", "alt text", "2026.1", + "strategy", new[] { "classic" }, new[] { "ranked" }); + + act.Should().Throw(); + } + + [Fact] + public void Create_AcceptsValidFullGame() + { + var game = ValidComingSoonGame(); + + game.Slug.Should().Be("chess-lite"); + game.Lifecycle.Should().Be(GameLifecycle.ComingSoon); + game.FeaturedRank.Should().Be(1); + game.Category.Should().Be("strategy"); + game.Tags.Should().BeEmpty(); + game.Capabilities.Select(c => c.Mode).Should().BeEquivalentTo("ai", "multiplayer", "ranked"); + } + + [Fact] + public void ApplyManifestUpdate_RejectsMinPlayersLessThanOne() + { + var game = ValidComingSoonGame(); + var act = () => game.ApplyManifestUpdate( + "Chess Lite", "Summary.", "Rules.", GameDifficulty.Hard, 5, 25, 0, 2, 1, 3, + "chess-lite", "#9B51E0", "#2D9CDB", "alt text", "2026.2", + "strategy", new[] { "classic" }, new[] { "ai", "multiplayer", "ranked" }); + + act.Should().Throw(); + } + + [Fact] + public void ApplyManifestUpdate_RejectsNonAllowListedTag() + { + var game = ValidComingSoonGame(); + var act = () => game.ApplyManifestUpdate( + "Chess Lite", "Summary.", "Rules.", GameDifficulty.Hard, 5, 25, 2, 2, 1, 3, + "chess-lite", "#9B51E0", "#2D9CDB", "alt text", "2026.2", + "strategy", new[] { "not-a-real-tag" }, new[] { "ai", "multiplayer", "ranked" }); + + act.Should().Throw(); + } + + [Fact] + public void ApplyManifestUpdate_LeavesSlugAndLifecycleUnchanged_AndReplacesFields() + { + var game = ValidComingSoonGame(); + game.MakeAvailable(); + + game.ApplyManifestUpdate( + "Chess Lite Updated", "New summary.", "New rules.", GameDifficulty.Medium, 10, 20, 2, 2, null, 9, + "chess-lite-v2", "#000000", "#FFFFFF", "new alt text", "2026.2", + "strategy", new[] { "classic" }, new[] { "multiplayer" }); + + game.Slug.Should().Be("chess-lite"); + game.Lifecycle.Should().Be(GameLifecycle.Available); + game.Name.Should().Be("Chess Lite Updated"); + game.FeaturedRank.Should().BeNull(); + game.SortOrder.Should().Be(9); + game.ManifestVersion.Should().Be("2026.2"); + game.Category.Should().Be("strategy"); + game.Tags.Select(t => t.Value).Should().BeEquivalentTo("classic"); + game.Capabilities.Select(c => c.Mode).Should().BeEquivalentTo("multiplayer"); + } +} diff --git a/tests/SimPle.UnitTests/Games/GamesServiceTests.cs b/tests/SimPle.UnitTests/Games/GamesServiceTests.cs new file mode 100644 index 0000000..090f3de --- /dev/null +++ b/tests/SimPle.UnitTests/Games/GamesServiceTests.cs @@ -0,0 +1,511 @@ +using FluentAssertions; +using NSubstitute; +using SimPle.Application.Common.Interfaces; +using SimPle.Application.Common.Pagination; +using SimPle.Application.Games; +using SimPle.Application.Games.Services; +using SimPle.Domain.Games; + +namespace SimPle.UnitTests.Games; + +/// +/// Service-layer tests for : validation happens before any repository call, cursor +/// shape-binding rejects forged/malformed/mismatched tokens (including a cursor whose sort key cannot decode +/// under the currently-requested sort — the repository would otherwise silently ignore it and restart at page +/// 1), the ETag hash is stable/changes with row data, entryActions is the fixed 5-action projection on every +/// DTO regardless of lifecycle, and favorites are idempotent per the spec's PUT/DELETE contract. +/// +public sealed class GamesServiceTests +{ + private readonly IGameRepository _games = Substitute.For(); + private readonly GamesService _service; + + private static readonly IReadOnlyList<(Guid, string, string)> NoExtras = Array.Empty<(Guid, string, string)>(); + + public GamesServiceTests() + { + _service = new GamesService(_games); + _games.GetTagsAndCapabilitiesAsync(Arg.Any>()).Returns(NoExtras); + } + + private static Game MakeGame( + string slug = "chess-lite", GameLifecycle lifecycle = GameLifecycle.ComingSoon, + int? featuredRank = null) => Game.Create( + slug: slug, name: "Chess Lite", summary: "A streamlined chess experience.", + rulesSummary: "Chess Lite wins by checkmate.", difficulty: GameDifficulty.Medium, + estimatedDurationMinMinutes: 5, estimatedDurationMaxMinutes: 25, + minPlayers: 2, maxPlayers: 2, initialLifecycle: lifecycle, + featuredRank: featuredRank, sortOrder: 1, artToken: "chess-lite", + artColorA: "#9B51E0", artColorB: "#2D9CDB", artAltText: "Chess Lite abstract game artwork", + manifestVersion: "2026.1", category: "strategy", tags: Array.Empty(), + modes: Array.Empty()); + + // ── List: validation rejected before touching the database ───────────── + + [Fact] + public async Task ListAsync_SearchTooShort_ValidationFailed_NeverQueriesRepository() + { + var result = await _service.ListAsync("a", null, null, null, null, null, 24, null); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be("Validation.Failed"); + await _games.DidNotReceive().GetCatalogPageAsync(Arg.Any()); + } + + [Fact] + public async Task ListAsync_EmptyOrWhitespaceQuery_IsTreatedAsNoSearchFilter_NotAValidationError() + { + _games.GetCatalogPageAsync(Arg.Any()).Returns(new List()); + + var result = await _service.ListAsync(" ", null, null, null, null, null, 24, null); + + result.IsSuccess.Should().BeTrue(); + await _games.Received(1).GetCatalogPageAsync(Arg.Is(f => f.NormalizedSearch == null)); + } + + [Fact] + public async Task ListAsync_SearchTooLong_ValidationFailed() + { + var result = await _service.ListAsync(new string('a', 101), null, null, null, null, null, 24, null); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be("Validation.Failed"); + } + + [Fact] + public async Task ListAsync_TooManyCategoryValues_ValidationFailed() + { + var categories = Enumerable.Range(0, 6).Select(_ => "puzzle").ToArray(); + + var result = await _service.ListAsync(null, categories, null, null, null, null, 24, null); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be("Validation.Failed"); + await _games.DidNotReceive().GetCatalogPageAsync(Arg.Any()); + } + + [Fact] + public async Task ListAsync_UnknownCategory_ValidationFailed() + { + var result = await _service.ListAsync(null, new[] { "not-a-real-category" }, null, null, null, null, 24, null); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be("Validation.Failed"); + } + + [Fact] + public async Task ListAsync_UnknownMode_ValidationFailed() + { + var result = await _service.ListAsync(null, null, null, new[] { "not-a-real-mode" }, null, null, 24, null); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be("Validation.Failed"); + } + + [Theory] + [InlineData("Draft")] + [InlineData("Retired")] + [InlineData("NotARealLifecycle")] + public async Task ListAsync_NonPublicLifecycleFilter_ValidationFailed(string lifecycle) + { + var result = await _service.ListAsync(null, null, null, null, new[] { lifecycle }, null, 24, null); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be("Validation.Failed"); + } + + [Fact] + public async Task ListAsync_UnknownSort_ValidationFailed() + { + var result = await _service.ListAsync(null, null, null, null, null, "not-a-sort", 24, null); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be("Validation.Failed"); + } + + [Theory] + [InlineData(0)] + [InlineData(51)] + public async Task ListAsync_PageSizeOutOfBounds_ValidationFailed(int limit) + { + var result = await _service.ListAsync(null, null, null, null, null, null, limit, null); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be("Validation.Failed"); + await _games.DidNotReceive().GetCatalogPageAsync(Arg.Any()); + } + + // ── List: cursor shape binding ─────────────────────────────────────────── + + [Fact] + public async Task ListAsync_MalformedCursor_InvalidCursor() + { + var result = await _service.ListAsync(null, null, null, null, null, null, 24, "not-a-valid-cursor!!!"); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be("Pagination.InvalidCursor"); + } + + [Fact] + public async Task ListAsync_CursorFromADifferentFilterShape_InvalidCursor() + { + _games.GetCatalogPageAsync(Arg.Any()) + .Returns(new List { MakeGame() }); + + var firstPage = await _service.ListAsync("chess", null, null, null, null, null, 1, null); + var cursorForChessSearch = firstPage.Value!.Page.NextCursor; + + // Re-issue the exact same cursor but drop the search term — a different normalized query shape. + var result = await _service.ListAsync(null, null, null, null, null, null, 1, cursorForChessSearch); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be("Pagination.InvalidCursor"); + } + + [Fact] + public async Task ListAsync_CursorSortKeyInvalidForActiveSort_InvalidCursor_DoesNotSilentlyRestart() + { + _games.GetCatalogPageAsync(Arg.Any()) + .Returns(new List { MakeGame() }); + + // Build a cursor for the default sort, then replay it against sort=name — the repository's name-sort + // branch would happily treat a non-empty sortKey as valid, but the *value* was minted under a + // different sort's key format, so the service must still recompute the shape hash and reject it. + var defaultPage = await _service.ListAsync(null, null, null, null, null, GameCatalogSortKey.Default, 1, null); + var cursorFromDefaultSort = defaultPage.Value!.Page.NextCursor; + + var result = await _service.ListAsync(null, null, null, null, null, GameCatalogSortKey.Name, 1, cursorFromDefaultSort); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be("Pagination.InvalidCursor"); + } + + [Fact] + public async Task ListAsync_NoLifecycleFilter_DefaultsToThePublicThreeLifecycles() + { + _games.GetCatalogPageAsync(Arg.Any()).Returns(new List()); + + await _service.ListAsync(null, null, null, null, null, null, 24, null); + + await _games.Received(1).GetCatalogPageAsync(Arg.Is(f => + f.Lifecycles.Count == 3 && + f.Lifecycles.Contains(GameLifecycle.ComingSoon) && + f.Lifecycles.Contains(GameLifecycle.Available) && + f.Lifecycles.Contains(GameLifecycle.Maintenance))); + } + + // ── entryActions: fixed projection regardless of lifecycle ────────────── + + [Theory] + [InlineData(GameLifecycle.ComingSoon)] + [InlineData(GameLifecycle.Available)] + [InlineData(GameLifecycle.Maintenance)] + public async Task GetDetailAsync_EntryActions_IsTheFixedFiveActionProjection(GameLifecycle lifecycle) + { + var game = MakeGame(lifecycle: lifecycle); + _games.GetBySlugAsync(game.Slug).Returns(game); + + var result = await _service.GetDetailAsync(game.Slug); + + result.IsSuccess.Should().BeTrue(); + result.Value!.Game!.EntryActions.Should().BeEquivalentTo(GameEntryActions.All); + result.Value.Game.EntryActions.Should().OnlyContain(a => a.Status == "deferred"); + } + + // ── Detail: 404 / 410 / 200 per lifecycle ──────────────────────────────── + + [Fact] + public async Task GetDetailAsync_UnknownSlug_NotFound() + { + _games.GetBySlugAsync("unknown-slug").Returns((Game?)null); + + var result = await _service.GetDetailAsync("unknown-slug"); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be("Games.NotFound"); + } + + [Fact] + public async Task GetDetailAsync_Draft_NotFound_IndistinguishableFromUnknown() + { + var game = MakeGame(lifecycle: GameLifecycle.Draft); + _games.GetBySlugAsync(game.Slug).Returns(game); + + var result = await _service.GetDetailAsync(game.Slug); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be("Games.NotFound"); + } + + [Fact] + public async Task GetDetailAsync_Retired_ReturnsMinimalTombstone_NoETag() + { + var game = MakeGame(lifecycle: GameLifecycle.ComingSoon); + game.MakeAvailable(); + game.Retire(); + _games.GetBySlugAsync(game.Slug).Returns(game); + + var result = await _service.GetDetailAsync(game.Slug); + + result.IsSuccess.Should().BeTrue(); + result.Value!.Game.Should().BeNull(); + result.Value.ETag.Should().BeNull(); + result.Value.Tombstone.Should().NotBeNull(); + result.Value.Tombstone!.Slug.Should().Be(game.Slug); + result.Value.Tombstone.ReasonCode.Should().Be("Games.Retired"); + } + + // ── ETag: stable for the same data, changes when it changes ───────────── + + [Fact] + public async Task GetDetailAsync_SameGameData_ProducesTheSameETagAcrossCalls() + { + var game = MakeGame(); + _games.GetBySlugAsync(game.Slug).Returns(game); + + var first = await _service.GetDetailAsync(game.Slug); + var second = await _service.GetDetailAsync(game.Slug); + + first.Value!.ETag.Should().Be(second.Value!.ETag); + } + + [Fact] + public async Task GetDetailAsync_AfterLifecycleTransition_ETagChanges() + { + var game = MakeGame(); + _games.GetBySlugAsync(game.Slug).Returns(game); + var before = await _service.GetDetailAsync(game.Slug); + + game.MakeAvailable(); // bumps LifecycleVersion + UpdatedAt + + var after = await _service.GetDetailAsync(game.Slug); + after.Value!.ETag.Should().NotBe(before.Value!.ETag); + } + + // ── Featured ────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetFeaturedAsync_NoneFeatured_ReturnsNullGameAndNullETag() + { + _games.GetFeaturedAsync().Returns((Game?)null); + + var result = await _service.GetFeaturedAsync(); + + result.IsSuccess.Should().BeTrue(); + result.Value!.Game.Should().BeNull(); + result.Value.ETag.Should().BeNull(); + } + + [Fact] + public async Task GetFeaturedAsync_Featured_ReturnsDtoAndETag() + { + var game = MakeGame(featuredRank: 1); + _games.GetFeaturedAsync().Returns(game); + + var result = await _service.GetFeaturedAsync(); + + result.Value!.Game.Should().NotBeNull(); + result.Value.ETag.Should().NotBeNullOrEmpty(); + } + + // ── Favorites: PUT ──────────────────────────────────────────────────────── + + [Fact] + public async Task PutFavoriteAsync_UnknownSlug_NotFound() + { + _games.GetBySlugAsync("unknown-slug").Returns((Game?)null); + + var result = await _service.PutFavoriteAsync(Guid.NewGuid(), "unknown-slug"); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be("Games.NotFound"); + } + + [Fact] + public async Task PutFavoriteAsync_NewFavorite_AddsAndReturnsDto() + { + var userId = Guid.NewGuid(); + var game = MakeGame(); + _games.GetBySlugAsync(game.Slug).Returns(game); + _games.GetFavoriteAsync(userId, game.Id).Returns((UserFavoriteGame?)null); + _games.AddFavoriteAsync(Arg.Any(), Arg.Any()) + .Returns(AddFavoriteOutcome.Added); + + var result = await _service.PutFavoriteAsync(userId, game.Slug); + + result.IsSuccess.Should().BeTrue(); + result.Value!.Slug.Should().Be(game.Slug); + await _games.Received(1).AddFavoriteAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task PutFavoriteAsync_AlreadyActiveFavorite_IsIdempotent_NoRepositoryWrite() + { + var userId = Guid.NewGuid(); + var game = MakeGame(); + var existing = UserFavoriteGame.Favorite(userId, game.Id); + _games.GetBySlugAsync(game.Slug).Returns(game); + _games.GetFavoriteAsync(userId, game.Id).Returns(existing); + + var result = await _service.PutFavoriteAsync(userId, game.Slug); + + result.IsSuccess.Should().BeTrue(); + result.Value!.Slug.Should().Be(game.Slug); + await _games.DidNotReceive().AddFavoriteAsync(Arg.Any(), Arg.Any()); + await _games.DidNotReceive().UpdateFavoriteAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task PutFavoriteAsync_InactiveFavorite_Refavorites() + { + var userId = Guid.NewGuid(); + var game = MakeGame(); + var existing = UserFavoriteGame.Favorite(userId, game.Id); + existing.Unfavorite(); + _games.GetBySlugAsync(game.Slug).Returns(game); + _games.GetFavoriteAsync(userId, game.Id).Returns(existing); + _games.UpdateFavoriteAsync(Arg.Any(), Arg.Any()) + .Returns(UpdateFavoriteOutcome.Updated); + + var result = await _service.PutFavoriteAsync(userId, game.Slug); + + result.IsSuccess.Should().BeTrue(); + existing.IsActive.Should().BeTrue(); + await _games.Received(1).UpdateFavoriteAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task PutFavoriteAsync_NewFavoriteOfRetiredGame_Conflict() + { + var userId = Guid.NewGuid(); + var game = MakeGame(); + game.MakeAvailable(); + game.Retire(); + _games.GetBySlugAsync(game.Slug).Returns(game); + _games.GetFavoriteAsync(userId, game.Id).Returns((UserFavoriteGame?)null); + + var result = await _service.PutFavoriteAsync(userId, game.Slug); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be("Games.Retired"); + } + + [Fact] + public async Task PutFavoriteAsync_ExistingOwnerStillSeesTombstone_EvenAfterGameRetires() + { + var userId = Guid.NewGuid(); + var game = MakeGame(); + var existing = UserFavoriteGame.Favorite(userId, game.Id); + game.MakeAvailable(); + game.Retire(); + _games.GetBySlugAsync(game.Slug).Returns(game); + _games.GetFavoriteAsync(userId, game.Id).Returns(existing); + + // An existing active favorite on a now-retired game is still idempotently reported back (retained + // per spec: "Retired game retains an existing owner's favorite row"). + var result = await _service.PutFavoriteAsync(userId, game.Slug); + + result.IsSuccess.Should().BeTrue(); + result.Value!.Lifecycle.Should().Be("Retired"); + } + + [Fact] + public async Task PutFavoriteAsync_AddConflict_ReReadsAndConverges() + { + var userId = Guid.NewGuid(); + var game = MakeGame(); + var winningRow = UserFavoriteGame.Favorite(userId, game.Id); + _games.GetBySlugAsync(game.Slug).Returns(game); + _games.GetFavoriteAsync(userId, game.Id).Returns((UserFavoriteGame?)null, winningRow); + _games.AddFavoriteAsync(Arg.Any(), Arg.Any()) + .Returns(AddFavoriteOutcome.Conflict); + + var result = await _service.PutFavoriteAsync(userId, game.Slug); + + result.IsSuccess.Should().BeTrue(); + result.Value!.Slug.Should().Be(game.Slug); + } + + // ── Favorites: DELETE ───────────────────────────────────────────────────── + + [Fact] + public async Task DeleteFavoriteAsync_UnknownSlug_NotFound() + { + _games.GetBySlugAsync("unknown-slug").Returns((Game?)null); + + var result = await _service.DeleteFavoriteAsync(Guid.NewGuid(), "unknown-slug"); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be("Games.NotFound"); + } + + [Fact] + public async Task DeleteFavoriteAsync_NoExistingRow_IsIdempotentSuccess_NoWrite() + { + var userId = Guid.NewGuid(); + var game = MakeGame(); + _games.GetBySlugAsync(game.Slug).Returns(game); + _games.GetFavoriteAsync(userId, game.Id).Returns((UserFavoriteGame?)null); + + var result = await _service.DeleteFavoriteAsync(userId, game.Slug); + + result.IsSuccess.Should().BeTrue(); + await _games.DidNotReceive().UpdateFavoriteAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task DeleteFavoriteAsync_AlreadyInactive_IsIdempotentSuccess_NoWrite() + { + var userId = Guid.NewGuid(); + var game = MakeGame(); + var existing = UserFavoriteGame.Favorite(userId, game.Id); + existing.Unfavorite(); + _games.GetBySlugAsync(game.Slug).Returns(game); + _games.GetFavoriteAsync(userId, game.Id).Returns(existing); + + var result = await _service.DeleteFavoriteAsync(userId, game.Slug); + + result.IsSuccess.Should().BeTrue(); + await _games.DidNotReceive().UpdateFavoriteAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task DeleteFavoriteAsync_ActiveFavorite_UnfavoritesAndWrites() + { + var userId = Guid.NewGuid(); + var game = MakeGame(); + var existing = UserFavoriteGame.Favorite(userId, game.Id); + _games.GetBySlugAsync(game.Slug).Returns(game); + _games.GetFavoriteAsync(userId, game.Id).Returns(existing); + _games.UpdateFavoriteAsync(Arg.Any(), Arg.Any()) + .Returns(UpdateFavoriteOutcome.Updated); + + var result = await _service.DeleteFavoriteAsync(userId, game.Slug); + + result.IsSuccess.Should().BeTrue(); + existing.IsActive.Should().BeFalse(); + await _games.Received(1).UpdateFavoriteAsync(Arg.Any(), Arg.Any()); + } + + // ── Favorites list: validation ──────────────────────────────────────────── + + [Theory] + [InlineData(0)] + [InlineData(51)] + public async Task GetFavoritesAsync_PageSizeOutOfBounds_ValidationFailed(int limit) + { + var result = await _service.GetFavoritesAsync(Guid.NewGuid(), limit, null); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be("Validation.Failed"); + } + + [Fact] + public async Task GetFavoritesAsync_MalformedCursor_InvalidCursor() + { + var result = await _service.GetFavoritesAsync(Guid.NewGuid(), 24, "not-a-valid-cursor!!!"); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be("Pagination.InvalidCursor"); + } +} diff --git a/tests/SimPle.UnitTests/Games/UserFavoriteGameTests.cs b/tests/SimPle.UnitTests/Games/UserFavoriteGameTests.cs new file mode 100644 index 0000000..f6ead07 --- /dev/null +++ b/tests/SimPle.UnitTests/Games/UserFavoriteGameTests.cs @@ -0,0 +1,63 @@ +using FluentAssertions; +using SimPle.Domain.Games; + +namespace SimPle.UnitTests.Games; + +/// Pure in-memory domain tests for favorite/unfavorite cycling. +public sealed class UserFavoriteGameTests +{ + [Fact] + public void Favorite_StartsActive_WithCycleIdOne() + { + var favorite = UserFavoriteGame.Favorite(Guid.NewGuid(), Guid.NewGuid()); + + favorite.IsActive.Should().BeTrue(); + favorite.CycleId.Should().Be(1); + } + + [Fact] + public void Unfavorite_SetsInactive_WithoutBumpingCycleId() + { + var favorite = UserFavoriteGame.Favorite(Guid.NewGuid(), Guid.NewGuid()); + + favorite.Unfavorite(); + + favorite.IsActive.Should().BeFalse(); + favorite.CycleId.Should().Be(1); + } + + [Fact] + public void Refavorite_SetsActive_AndBumpsCycleId() + { + var favorite = UserFavoriteGame.Favorite(Guid.NewGuid(), Guid.NewGuid()); + favorite.Unfavorite(); + + favorite.Refavorite(); + + favorite.IsActive.Should().BeTrue(); + favorite.CycleId.Should().Be(2); + } + + [Fact] + public void Unfavorite_WhenAlreadyInactive_IsNoOp() + { + var favorite = UserFavoriteGame.Favorite(Guid.NewGuid(), Guid.NewGuid()); + favorite.Unfavorite(); + + favorite.Unfavorite(); + + favorite.IsActive.Should().BeFalse(); + favorite.CycleId.Should().Be(1); + } + + [Fact] + public void Refavorite_WhenAlreadyActive_IsNoOp() + { + var favorite = UserFavoriteGame.Favorite(Guid.NewGuid(), Guid.NewGuid()); + + favorite.Refavorite(); + + favorite.IsActive.Should().BeTrue(); + favorite.CycleId.Should().Be(1); + } +}