Forgot unversioned xd
This commit is contained in:
175
SVSim.CardImport/Program.cs
Normal file
175
SVSim.CardImport/Program.cs
Normal file
@@ -0,0 +1,175 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.CardImport;
|
||||
|
||||
public static class Program
|
||||
{
|
||||
private const string DefaultConnectionString =
|
||||
"Host=localhost;Database=svsim;Username=postgres;password=postgres";
|
||||
|
||||
public static async Task<int> Main(string[] args)
|
||||
{
|
||||
if (args.Length < 1 || args[0] is "--help" or "-h")
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"Usage: svsim-card-import <cards.json> [connection-string]\n" +
|
||||
"\n" +
|
||||
" cards.json Path to the loader's card dump (LitJson array of CardCSVData)\n" +
|
||||
" connection-string Postgres connection (falls back to NPGSQL_CONNECTION env var,\n" +
|
||||
$" then \"{DefaultConnectionString}\")");
|
||||
return 1;
|
||||
}
|
||||
|
||||
string path = args[0];
|
||||
string connection = args.Length > 1
|
||||
? args[1]
|
||||
: Environment.GetEnvironmentVariable("NPGSQL_CONNECTION") ?? DefaultConnectionString;
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
Console.Error.WriteLine($"File not found: {path}");
|
||||
return 2;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Reading {path} ({new FileInfo(path).Length / 1024} KiB)...");
|
||||
var jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
NumberHandling = JsonNumberHandling.AllowReadingFromString,
|
||||
};
|
||||
|
||||
List<CardInput>? input;
|
||||
await using (var fs = File.OpenRead(path))
|
||||
{
|
||||
input = await JsonSerializer.DeserializeAsync<List<CardInput>>(fs, jsonOptions);
|
||||
}
|
||||
if (input is null || input.Count == 0)
|
||||
{
|
||||
Console.Error.WriteLine("No card records parsed from input.");
|
||||
return 3;
|
||||
}
|
||||
Console.WriteLine($"Parsed {input.Count} card records.");
|
||||
|
||||
var dbOptions = new DbContextOptionsBuilder<SVSimDbContext>()
|
||||
.UseNpgsql(connection)
|
||||
.Options;
|
||||
|
||||
await using var context = new SVSimDbContext(NullLogger<SVSimDbContext>.Instance, dbOptions);
|
||||
|
||||
// Apply any pending migrations first — bootstraps a fresh DB so CardImport can be the
|
||||
// very first thing run after `dotnet ef migrations add` (no need to run the server too).
|
||||
// Migration files have InsertData rows for the seeded master data already; runtime seeder
|
||||
// skip is fine.
|
||||
await context.Database.MigrateAsync();
|
||||
|
||||
var classesById = await context.Classes.ToDictionaryAsync(c => c.Id);
|
||||
var existingSets = (await context.CardSets.ToListAsync()).ToDictionary(s => s.Id);
|
||||
var existingCards = (await context.Cards.ToListAsync()).ToDictionary(c => c.Id);
|
||||
Console.WriteLine(
|
||||
$"DB state before: {existingCards.Count} cards, {existingSets.Count} card sets, " +
|
||||
$"{classesById.Count} classes seeded.");
|
||||
|
||||
int created = 0, updated = 0, skipped = 0, setsCreated = 0;
|
||||
|
||||
foreach (var c in input)
|
||||
{
|
||||
if (!long.TryParse(c.CardId, out long id) || id == 0)
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
int setId = ParseInt(c.CardSetId, 0);
|
||||
int clan = ParseInt(c.Clan, 0);
|
||||
int rarity = ParseInt(c.Rarity, 0);
|
||||
|
||||
if (!existingSets.TryGetValue(setId, out var set))
|
||||
{
|
||||
set = new ShadowverseCardSetEntry
|
||||
{
|
||||
Id = setId,
|
||||
Name = $"Card Set {setId}",
|
||||
IsInRotation = true,
|
||||
IsBasic = false
|
||||
};
|
||||
context.CardSets.Add(set);
|
||||
existingSets[setId] = set;
|
||||
setsCreated++;
|
||||
}
|
||||
|
||||
ClassEntry? classEntry = clan > 0 && classesById.TryGetValue(clan, out var ce) ? ce : null;
|
||||
var collection = new CardCollectionInfo
|
||||
{
|
||||
CraftCost = ParseInt(c.UseRedEther, 0),
|
||||
DustReward = ParseInt(c.GetRedEther, 0)
|
||||
};
|
||||
|
||||
if (existingCards.TryGetValue(id, out var card))
|
||||
{
|
||||
card.Rarity = (Rarity)rarity;
|
||||
card.PrimaryResourceCost = ParseNullableInt(c.Cost);
|
||||
card.Attack = ParseNullableInt(c.Atk);
|
||||
card.Defense = ParseNullableInt(c.Life);
|
||||
card.Class = classEntry;
|
||||
card.CollectionInfo = collection;
|
||||
updated++;
|
||||
}
|
||||
else
|
||||
{
|
||||
card = new ShadowverseCardEntry
|
||||
{
|
||||
Id = id,
|
||||
Name = $"Card {id}",
|
||||
Rarity = (Rarity)rarity,
|
||||
PrimaryResourceCost = ParseNullableInt(c.Cost),
|
||||
Attack = ParseNullableInt(c.Atk),
|
||||
Defense = ParseNullableInt(c.Life),
|
||||
Class = classEntry,
|
||||
CollectionInfo = collection
|
||||
};
|
||||
set.Cards.Add(card);
|
||||
existingCards[id] = card;
|
||||
created++;
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
$"Saving: +{created} cards, ~{updated} updated, +{setsCreated} card sets, " +
|
||||
$"skipped {skipped} (bad/missing card_id)...");
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
Console.WriteLine("Done.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int ParseInt(string? raw, int fallback) =>
|
||||
int.TryParse(raw, out int v) ? v : fallback;
|
||||
|
||||
private static int? ParseNullableInt(string? raw) =>
|
||||
int.TryParse(raw, out int v) ? v : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight projection over the CardCSVData fields we care about. The dump has many more
|
||||
/// fields (PascalCase metadata + effect/voice/visual paths) — we ignore them; only the
|
||||
/// snake_case CSV columns map here via the SnakeCaseLower naming policy.
|
||||
/// </summary>
|
||||
public class CardInput
|
||||
{
|
||||
public string? CardId { get; set; }
|
||||
public string? CardSetId { get; set; }
|
||||
public string? Clan { get; set; }
|
||||
public string? Cost { get; set; }
|
||||
public string? Atk { get; set; }
|
||||
public string? Life { get; set; }
|
||||
public string? Rarity { get; set; }
|
||||
public string? GetRedEther { get; set; }
|
||||
public string? UseRedEther { get; set; }
|
||||
}
|
||||
16
SVSim.CardImport/SVSim.CardImport.csproj
Normal file
16
SVSim.CardImport/SVSim.CardImport.csproj
Normal file
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>SVSim.CardImport</RootNamespace>
|
||||
<AssemblyName>svsim-card-import</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SVSim.Database\SVSim.Database.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
15
SVSim.Database/Common/BaseEntity.cs
Normal file
15
SVSim.Database/Common/BaseEntity.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace SVSim.Database.Common;
|
||||
|
||||
public class BaseEntity<TKey> : ITimeTrackedEntity
|
||||
{
|
||||
[Key]
|
||||
[DatabaseGenerated(DatabaseGeneratedOption.None)]
|
||||
public virtual TKey Id { get; set; }
|
||||
|
||||
public DateTime DateCreated { get; set; } = DateTime.MinValue;
|
||||
|
||||
public DateTime? DateUpdated { get; set; }
|
||||
}
|
||||
8
SVSim.Database/Common/IDataSeeder.cs
Normal file
8
SVSim.Database/Common/IDataSeeder.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace SVSim.Database.Common;
|
||||
|
||||
public interface IDataSeeder
|
||||
{
|
||||
void Seed(ModelBuilder builder);
|
||||
}
|
||||
14
SVSim.Database/Common/ITimeTrackedEntity.cs
Normal file
14
SVSim.Database/Common/ITimeTrackedEntity.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace SVSim.Database.Common;
|
||||
|
||||
public interface ITimeTrackedEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// The <see cref="DateTime"/> this entity was first added to the database.
|
||||
/// </summary>
|
||||
DateTime DateCreated { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="DateTime"/> this entity was last updated.
|
||||
/// </summary>
|
||||
DateTime? DateUpdated { get; set; }
|
||||
}
|
||||
33857
SVSim.Database/Migrations/20260523152741_Initial.Designer.cs
generated
Normal file
33857
SVSim.Database/Migrations/20260523152741_Initial.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
7071
SVSim.Database/Migrations/20260523152741_Initial.cs
Normal file
7071
SVSim.Database/Migrations/20260523152741_Initial.cs
Normal file
File diff suppressed because it is too large
Load Diff
14
SVSim.Database/Models/DeckCard.cs
Normal file
14
SVSim.Database/Models/DeckCard.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="ShadowverseCardEntry"/> that appears in a <see cref="ShadowverseDeckEntry"/> N times.
|
||||
/// </summary>
|
||||
[Owned]
|
||||
public class DeckCard
|
||||
{
|
||||
public ShadowverseCardEntry Card { get; set; } = new ShadowverseCardEntry();
|
||||
|
||||
public int Count { get; set; }
|
||||
}
|
||||
90
SVSim.Database/Repositories/Deck/DeckRepository.cs
Normal file
90
SVSim.Database/Repositories/Deck/DeckRepository.cs
Normal file
@@ -0,0 +1,90 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Database.Repositories.Deck;
|
||||
|
||||
public class DeckRepository : IDeckRepository
|
||||
{
|
||||
private const int MaxDecksPerFormat = 50;
|
||||
|
||||
private readonly SVSimDbContext _dbContext;
|
||||
|
||||
public DeckRepository(SVSimDbContext dbContext)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
}
|
||||
|
||||
public async Task<List<ShadowverseDeckEntry>> GetDecks(long viewerId, Format format)
|
||||
{
|
||||
var viewer = await _dbContext.Viewers
|
||||
.AsNoTracking()
|
||||
.Include(v => v.Decks).ThenInclude(d => d.Class)
|
||||
.Include(v => v.Decks).ThenInclude(d => d.Sleeve)
|
||||
.Include(v => v.Decks).ThenInclude(d => d.LeaderSkin)
|
||||
.FirstOrDefaultAsync(v => v.Id == viewerId);
|
||||
|
||||
return viewer?.Decks.Where(d => d.Format == format).OrderBy(d => d.Number).ToList()
|
||||
?? new List<ShadowverseDeckEntry>();
|
||||
}
|
||||
|
||||
public async Task<ShadowverseDeckEntry?> GetDeck(long viewerId, Format format, int deckNo)
|
||||
{
|
||||
var viewer = await _dbContext.Viewers
|
||||
.AsNoTracking()
|
||||
.Include(v => v.Decks).ThenInclude(d => d.Class)
|
||||
.Include(v => v.Decks).ThenInclude(d => d.Sleeve)
|
||||
.Include(v => v.Decks).ThenInclude(d => d.LeaderSkin)
|
||||
.FirstOrDefaultAsync(v => v.Id == viewerId);
|
||||
|
||||
return viewer?.Decks.FirstOrDefault(d => d.Format == format && d.Number == deckNo);
|
||||
}
|
||||
|
||||
public async Task<int> GetEmptyDeckNumber(long viewerId, Format format)
|
||||
{
|
||||
var taken = (await GetDecks(viewerId, format)).Select(d => d.Number).ToHashSet();
|
||||
for (int i = 1; i <= MaxDecksPerFormat; i++)
|
||||
{
|
||||
if (!taken.Contains(i)) return i;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
public async Task<ShadowverseDeckEntry> UpsertDeck(long viewerId, Format format, int deckNo,
|
||||
Action<ShadowverseDeckEntry> mutate)
|
||||
{
|
||||
var viewer = await _dbContext.Viewers
|
||||
.Include(v => v.Decks).ThenInclude(d => d.Class)
|
||||
.Include(v => v.Decks).ThenInclude(d => d.Sleeve)
|
||||
.Include(v => v.Decks).ThenInclude(d => d.LeaderSkin)
|
||||
.FirstOrDefaultAsync(v => v.Id == viewerId)
|
||||
?? throw new InvalidOperationException($"Viewer {viewerId} not found.");
|
||||
|
||||
var deck = viewer.Decks.FirstOrDefault(d => d.Format == format && d.Number == deckNo);
|
||||
if (deck is null)
|
||||
{
|
||||
deck = new ShadowverseDeckEntry { Format = format, Number = deckNo };
|
||||
viewer.Decks.Add(deck);
|
||||
}
|
||||
|
||||
mutate(deck);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
return deck;
|
||||
}
|
||||
|
||||
public async Task DeleteDecks(long viewerId, Format format, IEnumerable<int> deckNos)
|
||||
{
|
||||
var viewer = await _dbContext.Viewers
|
||||
.Include(v => v.Decks)
|
||||
.FirstOrDefaultAsync(v => v.Id == viewerId);
|
||||
if (viewer is null) return;
|
||||
|
||||
var nos = deckNos.ToHashSet();
|
||||
var toRemove = viewer.Decks.Where(d => d.Format == format && nos.Contains(d.Number)).ToList();
|
||||
// Decks.ViewerId is nullable, so removing from the collection alone just orphans the
|
||||
// row (clears ViewerId, leaves the deck in the DB). Delete from the DbSet directly so
|
||||
// is_delete=1 and /deck/delete_deck_list actually remove the row.
|
||||
_dbContext.Decks.RemoveRange(toRemove);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
13
SVSim.Database/Repositories/Deck/IDeckRepository.cs
Normal file
13
SVSim.Database/Repositories/Deck/IDeckRepository.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Database.Repositories.Deck;
|
||||
|
||||
public interface IDeckRepository
|
||||
{
|
||||
Task<List<ShadowverseDeckEntry>> GetDecks(long viewerId, Format format);
|
||||
Task<ShadowverseDeckEntry?> GetDeck(long viewerId, Format format, int deckNo);
|
||||
Task<int> GetEmptyDeckNumber(long viewerId, Format format);
|
||||
Task<ShadowverseDeckEntry> UpsertDeck(long viewerId, Format format, int deckNo, Action<ShadowverseDeckEntry> mutate);
|
||||
Task DeleteDecks(long viewerId, Format format, IEnumerable<int> deckNos);
|
||||
}
|
||||
147
SVSim.EmulatedEntrypoint/Controllers/AdminController.cs
Normal file
147
SVSim.EmulatedEntrypoint/Controllers/AdminController.cs
Normal file
@@ -0,0 +1,147 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Repositories.Viewer;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Admin;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Admin;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Util endpoints for bootstrapping the dev environment. Anonymous-allowed today — security
|
||||
/// audit pending (don't expose these to the public internet).
|
||||
/// </summary>
|
||||
public class AdminController : SVSimController
|
||||
{
|
||||
private readonly IViewerRepository _viewerRepository;
|
||||
private readonly SVSimDbContext _dbContext;
|
||||
|
||||
public AdminController(IViewerRepository viewerRepository, SVSimDbContext dbContext)
|
||||
{
|
||||
_viewerRepository = viewerRepository;
|
||||
_dbContext = dbContext;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upsert a viewer from external data (typically captured from the live game via the
|
||||
/// SVSimLoader dump). Matches existing viewers by SteamId; creates a new one if missing.
|
||||
/// Only essential fields are imported today — extend as needed.
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
[HttpPost("import_viewer")]
|
||||
public async Task<ActionResult<ImportViewerResponse>> ImportViewer(ImportViewerRequest request)
|
||||
{
|
||||
if (request.SteamId == 0)
|
||||
{
|
||||
return BadRequest("steam_id is required");
|
||||
}
|
||||
|
||||
// SocialAccountConnection is [Owned]-by-Viewer — can't query the owned table directly;
|
||||
// look up the Viewer with a matching owned connection instead.
|
||||
var existing = await _dbContext.Viewers
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(v => v.SocialAccountConnections.Any(sac =>
|
||||
sac.AccountType == SocialAccountType.Steam && sac.AccountId == request.SteamId));
|
||||
|
||||
long viewerId;
|
||||
bool wasCreated;
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
var created = await _viewerRepository.RegisterViewer(
|
||||
request.DisplayName ?? "Imported Viewer",
|
||||
SocialAccountType.Steam,
|
||||
request.SteamId);
|
||||
viewerId = created.Id;
|
||||
wasCreated = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
viewerId = existing.Id;
|
||||
wasCreated = false;
|
||||
}
|
||||
|
||||
// Reload with all the nav properties we need to mutate. RegisterViewer SaveChanges'd
|
||||
// already, so we re-fetch with full graph and apply the updates.
|
||||
var viewer = await _dbContext.Viewers
|
||||
.Include(v => v.Info).ThenInclude(i => i.SelectedEmblem)
|
||||
.Include(v => v.Info).ThenInclude(i => i.SelectedDegree)
|
||||
.Include(v => v.Currency)
|
||||
.Include(v => v.MissionData)
|
||||
.Include(v => v.Classes).ThenInclude(c => c.Class)
|
||||
.Include(v => v.Sleeves)
|
||||
.Include(v => v.Emblems)
|
||||
.Include(v => v.Degrees)
|
||||
.Include(v => v.LeaderSkins)
|
||||
.Include(v => v.MyPageBackgrounds)
|
||||
.FirstAsync(v => v.Id == viewerId);
|
||||
|
||||
if (request.DisplayName is not null) viewer.DisplayName = request.DisplayName;
|
||||
if (request.CountryCode is not null) viewer.Info.CountryCode = request.CountryCode;
|
||||
if (request.TutorialState.HasValue) viewer.MissionData.TutorialState = request.TutorialState.Value;
|
||||
|
||||
if (request.Currency is not null)
|
||||
{
|
||||
if (request.Currency.Crystals.HasValue) viewer.Currency.Crystals = request.Currency.Crystals.Value;
|
||||
if (request.Currency.Rupees.HasValue) viewer.Currency.Rupees = request.Currency.Rupees.Value;
|
||||
if (request.Currency.RedEther.HasValue) viewer.Currency.RedEther = request.Currency.RedEther.Value;
|
||||
}
|
||||
|
||||
if (request.SelectedEmblemId.HasValue)
|
||||
{
|
||||
var emblem = await _dbContext.Emblems.FindAsync(request.SelectedEmblemId.Value);
|
||||
if (emblem is not null) viewer.Info.SelectedEmblem = emblem;
|
||||
}
|
||||
if (request.SelectedDegreeId.HasValue)
|
||||
{
|
||||
var degree = await _dbContext.Degrees.FindAsync(request.SelectedDegreeId.Value);
|
||||
if (degree is not null) viewer.Info.SelectedDegree = degree;
|
||||
}
|
||||
|
||||
await ReplaceOwned(viewer.Sleeves, request.OwnedSleeveIds, _dbContext.Sleeves);
|
||||
await ReplaceOwned(viewer.Emblems, request.OwnedEmblemIds, _dbContext.Emblems);
|
||||
await ReplaceOwned(viewer.Degrees, request.OwnedDegreeIds, _dbContext.Degrees);
|
||||
await ReplaceOwned(viewer.LeaderSkins, request.OwnedLeaderSkinIds, _dbContext.LeaderSkins);
|
||||
await ReplaceOwned(viewer.MyPageBackgrounds, request.OwnedMyPageBackgroundIds, _dbContext.MyPageBackgrounds);
|
||||
|
||||
if (request.Classes is not null)
|
||||
{
|
||||
foreach (var importClass in request.Classes)
|
||||
{
|
||||
var existingClass = viewer.Classes.FirstOrDefault(c => c.Class.Id == importClass.ClassId);
|
||||
if (existingClass is not null)
|
||||
{
|
||||
existingClass.Level = importClass.Level;
|
||||
existingClass.Exp = importClass.Exp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
return new ImportViewerResponse
|
||||
{
|
||||
ViewerId = viewer.Id,
|
||||
ShortUdid = viewer.ShortUdid,
|
||||
WasCreated = wasCreated
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the owned-collection with the master rows matching the supplied ids.
|
||||
/// Null `ids` is a no-op (preserve existing). Empty list clears the collection.
|
||||
/// </summary>
|
||||
private async Task ReplaceOwned<TEntity>(List<TEntity> owned, List<int>? ids, DbSet<TEntity> table)
|
||||
where TEntity : class
|
||||
{
|
||||
if (ids is null) return;
|
||||
owned.Clear();
|
||||
if (ids.Count == 0) return;
|
||||
|
||||
var rows = await table.Where(e => ids.Contains(EF.Property<int>(e, "Id"))).ToListAsync();
|
||||
owned.AddRange(rows);
|
||||
}
|
||||
}
|
||||
213
SVSim.EmulatedEntrypoint/Controllers/DeckController.cs
Normal file
213
SVSim.EmulatedEntrypoint/Controllers/DeckController.cs
Normal file
@@ -0,0 +1,213 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Repositories.Deck;
|
||||
using SVSim.EmulatedEntrypoint.Constants;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Common;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Deck;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Deck;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
|
||||
public class DeckController : SVSimController
|
||||
{
|
||||
private readonly IDeckRepository _deckRepository;
|
||||
private readonly SVSimDbContext _dbContext;
|
||||
|
||||
public DeckController(IDeckRepository deckRepository, SVSimDbContext dbContext)
|
||||
{
|
||||
_deckRepository = deckRepository;
|
||||
_dbContext = dbContext;
|
||||
}
|
||||
|
||||
// TODO: API-side deck_format values may not match the Format enum (see audit on /load/index
|
||||
// open question — `Data.FormatConvertApi` reverse-mapping not yet captured). For now we cast
|
||||
// directly; verify against live traffic and add a conversion table if mismatched.
|
||||
private static Format AsFormat(int apiValue) => (Format)apiValue;
|
||||
|
||||
[HttpPost("info")]
|
||||
public async Task<ActionResult<DeckListResponse>> Info(DeckInfoRequest request)
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
var decks = await _deckRepository.GetDecks(viewerId, AsFormat(request.DeckFormat));
|
||||
return new DeckListResponse
|
||||
{
|
||||
UserDeckList = decks.Select(d => new UserDeck(d)).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("my_list")]
|
||||
public async Task<ActionResult<DeckListResponse>> MyList(DeckFormatRequest request)
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
var decks = await _deckRepository.GetDecks(viewerId, AsFormat(request.DeckFormat));
|
||||
return new DeckListResponse
|
||||
{
|
||||
UserDeckList = decks.Select(d => new UserDeck(d)).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("get_empty_deck_number")]
|
||||
public async Task<ActionResult<EmptyDeckNumberResponse>> GetEmptyDeckNumber(DeckFormatRequest request)
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
return new EmptyDeckNumberResponse
|
||||
{
|
||||
EmptyDeckNum = await _deckRepository.GetEmptyDeckNumber(viewerId, AsFormat(request.DeckFormat))
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("update")]
|
||||
public async Task<ActionResult<DeckUpdateResponse>> Update(DeckUpdateRequest request)
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
var format = AsFormat(request.DeckFormat);
|
||||
|
||||
if (request.IsDelete == 1)
|
||||
{
|
||||
await _deckRepository.DeleteDecks(viewerId, format, new[] { request.DeckNo });
|
||||
}
|
||||
else
|
||||
{
|
||||
var cls = await _dbContext.Classes.FindAsync(request.ClassId);
|
||||
var sleeve = await _dbContext.Sleeves.FindAsync((int)request.SleeveId);
|
||||
var skin = await _dbContext.LeaderSkins.FindAsync(request.LeaderSkinId);
|
||||
var cards = await ResolveDeckCards(request.CardIdArray);
|
||||
|
||||
await _deckRepository.UpsertDeck(viewerId, format, request.DeckNo, deck =>
|
||||
{
|
||||
deck.Name = request.DeckName ?? string.Empty;
|
||||
if (cls is not null) deck.Class = cls;
|
||||
if (sleeve is not null) deck.Sleeve = sleeve;
|
||||
if (skin is not null) deck.LeaderSkin = skin;
|
||||
deck.RandomLeaderSkin = request.IsRandomLeaderSkin;
|
||||
deck.Cards = cards;
|
||||
});
|
||||
}
|
||||
|
||||
var decks = await _deckRepository.GetDecks(viewerId, format);
|
||||
return new DeckUpdateResponse
|
||||
{
|
||||
UserDeckList = decks.Select(d => new UserDeck(d)).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("update_name")]
|
||||
public async Task<ActionResult<SingleDeckResponse>> UpdateName(DeckUpdateNameRequest request)
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
var deck = await _deckRepository.UpsertDeck(viewerId, AsFormat(request.DeckFormat), request.DeckNo,
|
||||
d => d.Name = request.DeckName ?? string.Empty);
|
||||
return new SingleDeckResponse { UserDeck = new UserDeck(deck) };
|
||||
}
|
||||
|
||||
[HttpPost("update_sleeve")]
|
||||
public async Task<ActionResult<SingleDeckResponse>> UpdateSleeve(DeckUpdateSleeveRequest request)
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
var sleeve = await _dbContext.Sleeves.FindAsync((int)request.SleeveId);
|
||||
if (sleeve is null) return BadRequest($"Unknown sleeve {request.SleeveId}");
|
||||
|
||||
var deck = await _deckRepository.UpsertDeck(viewerId, AsFormat(request.DeckFormat), request.DeckNo,
|
||||
d => d.Sleeve = sleeve);
|
||||
return new SingleDeckResponse { UserDeck = new UserDeck(deck) };
|
||||
}
|
||||
|
||||
[HttpPost("update_leader_skin")]
|
||||
public async Task<ActionResult<SingleDeckResponse>> UpdateLeaderSkin(DeckUpdateLeaderSkinRequest request)
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
var skin = await _dbContext.LeaderSkins.FindAsync(request.LeaderSkinId);
|
||||
if (skin is null) return BadRequest($"Unknown leader skin {request.LeaderSkinId}");
|
||||
|
||||
var deck = await _deckRepository.UpsertDeck(viewerId, AsFormat(request.DeckFormat), request.DeckNo,
|
||||
d =>
|
||||
{
|
||||
d.LeaderSkin = skin;
|
||||
d.RandomLeaderSkin = false;
|
||||
});
|
||||
return new SingleDeckResponse { UserDeck = new UserDeck(deck) };
|
||||
}
|
||||
|
||||
// TODO: schema doesn't yet model the random-leader-skin pool — we just pick one and persist
|
||||
// that. Add a join table (DeckLeaderSkinPool) when ranked play / random skins become a real
|
||||
// feature. For now the UI flow still works (server returns a single chosen skin per spec).
|
||||
[HttpPost("update_random_leader_skin")]
|
||||
public async Task<ActionResult<SingleDeckResponse>> UpdateRandomLeaderSkin(DeckUpdateRandomLeaderSkinRequest request)
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
var pool = request.LeaderSkinIdList ?? new List<int>();
|
||||
if (pool.Count == 0) return BadRequest("leader_skin_id_list must contain at least one id");
|
||||
|
||||
int chosenId = pool[Random.Shared.Next(pool.Count)];
|
||||
var skin = await _dbContext.LeaderSkins.FindAsync(chosenId);
|
||||
if (skin is null) return BadRequest($"Unknown leader skin {chosenId}");
|
||||
|
||||
var deck = await _deckRepository.UpsertDeck(viewerId, AsFormat(request.DeckFormat), request.DeckNo,
|
||||
d =>
|
||||
{
|
||||
d.LeaderSkin = skin;
|
||||
d.RandomLeaderSkin = true;
|
||||
});
|
||||
return new SingleDeckResponse { UserDeck = new UserDeck(deck) };
|
||||
}
|
||||
|
||||
[HttpPost("update_order")]
|
||||
public async Task<ActionResult<EmptyResponse>> UpdateOrder(DeckOrderRequest request)
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
// Deck `Number` IS the slot order — the client sends the same slot numbers in a new
|
||||
// sequence. Today we don't model "display order" separately from "slot number", so
|
||||
// reordering is a no-op server-side. When a separate Order column lands, persist here.
|
||||
return new EmptyResponse();
|
||||
}
|
||||
|
||||
[HttpPost("delete_deck_list")]
|
||||
public async Task<ActionResult<EmptyResponse>> DeleteDeckList(DeckDeleteListRequest request)
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
var nos = request.DeckNoList ?? new List<int>();
|
||||
if (nos.Count > 0) await _deckRepository.DeleteDecks(viewerId, AsFormat(request.DeckFormat), nos);
|
||||
return new EmptyResponse();
|
||||
}
|
||||
|
||||
// /deck/set_deck_redis — server side is a Redis-cached "active deck per class" hint for
|
||||
// matchmaking. We don't model matchmaking yet; acknowledge the call and move on (real
|
||||
// server may not persist this either; the `_redis` suffix suggests cache-only).
|
||||
[HttpPost("set_deck_redis")]
|
||||
public Task<ActionResult<EmptyResponse>> SetDeckRedis(SetDeckRedisRequest request)
|
||||
{
|
||||
if (!TryGetViewerId(out long _)) return Task.FromResult<ActionResult<EmptyResponse>>(Unauthorized());
|
||||
return Task.FromResult<ActionResult<EmptyResponse>>(new EmptyResponse());
|
||||
}
|
||||
|
||||
private bool TryGetViewerId(out long viewerId)
|
||||
{
|
||||
viewerId = 0;
|
||||
var claim = User.Claims.FirstOrDefault(c => c.Type == ShadowverseClaimTypes.ViewerIdClaim)?.Value;
|
||||
return claim is not null && long.TryParse(claim, out viewerId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert a flat `card_id_array` (cards repeated for count) into a grouped DeckCard list.
|
||||
/// Cards not in the DB are silently dropped — until CardImport lands the result is always
|
||||
/// empty, which is acceptable for the deck-editing flow (UI saves what it can).
|
||||
/// </summary>
|
||||
private async Task<List<DeckCard>> ResolveDeckCards(List<long>? cardIdArray)
|
||||
{
|
||||
if (cardIdArray is null || cardIdArray.Count == 0) return new List<DeckCard>();
|
||||
|
||||
var grouped = cardIdArray.GroupBy(id => id).Select(g => new { Id = g.Key, Count = g.Count() }).ToList();
|
||||
var ids = grouped.Select(g => g.Id).ToList();
|
||||
var cards = await _dbContext.Cards.Where(c => ids.Contains(c.Id)).ToDictionaryAsync(c => c.Id);
|
||||
|
||||
return grouped
|
||||
.Where(g => cards.ContainsKey(g.Id))
|
||||
.Select(g => new DeckCard { Card = cards[g.Id], Count = g.Count })
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
133
SVSim.EmulatedEntrypoint/Controllers/PracticeController.cs
Normal file
133
SVSim.EmulatedEntrypoint/Controllers/PracticeController.cs
Normal file
@@ -0,0 +1,133 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Repositories.Viewer;
|
||||
using SVSim.EmulatedEntrypoint.Constants;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Common;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Practice;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Practice;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
|
||||
public class PracticeController : SVSimController
|
||||
{
|
||||
// Hand-curated AI opponents (audit B14 pattern). Replace with master data when the
|
||||
// practice subsystem is built out.
|
||||
private static readonly List<PracticeOpponent> StubOpponents = new()
|
||||
{
|
||||
new PracticeOpponent
|
||||
{
|
||||
PracticeId = 1,
|
||||
TextId = "Practice_001",
|
||||
ClassId = 1,
|
||||
CharaId = 1,
|
||||
DegreeId = 0,
|
||||
AiDeckLevel = 1,
|
||||
AiLogicLevel = 1,
|
||||
AiMaxLife = 20,
|
||||
Battle3dFieldId = "1",
|
||||
IsCampaignPractice = false
|
||||
},
|
||||
new PracticeOpponent
|
||||
{
|
||||
PracticeId = 2,
|
||||
TextId = "Practice_002",
|
||||
ClassId = 2,
|
||||
CharaId = 2,
|
||||
DegreeId = 0,
|
||||
AiDeckLevel = 2,
|
||||
AiLogicLevel = 2,
|
||||
AiMaxLife = 20,
|
||||
Battle3dFieldId = "1",
|
||||
IsCampaignPractice = false
|
||||
},
|
||||
new PracticeOpponent
|
||||
{
|
||||
PracticeId = 3,
|
||||
TextId = "Practice_003",
|
||||
ClassId = 3,
|
||||
CharaId = 3,
|
||||
DegreeId = 0,
|
||||
AiDeckLevel = 3,
|
||||
AiLogicLevel = 3,
|
||||
AiMaxLife = 25,
|
||||
Battle3dFieldId = "1",
|
||||
IsCampaignPractice = false
|
||||
}
|
||||
};
|
||||
|
||||
private readonly IViewerRepository _viewerRepository;
|
||||
|
||||
public PracticeController(IViewerRepository viewerRepository)
|
||||
{
|
||||
_viewerRepository = viewerRepository;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// /practice/info — returns the AI opponent catalog. Response data is a JSON array
|
||||
/// directly (not wrapped in an object), per spec.
|
||||
/// </summary>
|
||||
[HttpPost("info")]
|
||||
public Task<List<PracticeOpponent>> Info(BaseRequest request)
|
||||
{
|
||||
return Task.FromResult(StubOpponents);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// /practice/deck_list — returns viewer's decks scoped by format (always Format.All
|
||||
/// per spec, server can ignore the request field).
|
||||
/// </summary>
|
||||
[HttpPost("deck_list")]
|
||||
public async Task<ActionResult<PracticeDeckListResponse>> DeckList(DeckFormatRequest request)
|
||||
{
|
||||
var shortUdidClaim = User.Claims.FirstOrDefault(c => c.Type == ShadowverseClaimTypes.ShortUdidClaim)?.Value;
|
||||
if (shortUdidClaim is null || !long.TryParse(shortUdidClaim, out long shortUdid))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
Viewer? viewer = await _viewerRepository.GetViewerByShortUdid(shortUdid);
|
||||
if (viewer is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return new PracticeDeckListResponse
|
||||
{
|
||||
MaintenanceCardList = new List<long>(),
|
||||
UserDeckRotation = viewer.Decks.Where(d => d.Format == Format.Rotation)
|
||||
.Select(d => new UserDeck(d)).ToList(),
|
||||
UserDeckUnlimited = viewer.Decks.Where(d => d.Format == Format.Unlimited)
|
||||
.Select(d => new UserDeck(d)).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// /practice/start — server is essentially a no-op for practice. Spec: empty body
|
||||
/// response is fine; client tolerates missing mission_parameter.
|
||||
/// </summary>
|
||||
[HttpPost("start")]
|
||||
public Task<PracticeStartResponse> Start(BaseRequest request)
|
||||
{
|
||||
return Task.FromResult(new PracticeStartResponse());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// /practice/finish — accept the recovery_data blob without validation; return zero
|
||||
/// XP / no rewards. Class XP bookkeeping is deferred until a per-class XP store exists.
|
||||
/// </summary>
|
||||
[HttpPost("finish")]
|
||||
public Task<PracticeFinishResponse> Finish(PracticeFinishRequest request)
|
||||
{
|
||||
return Task.FromResult(new PracticeFinishResponse
|
||||
{
|
||||
GetClassExperience = 0,
|
||||
ClassExperience = 0,
|
||||
ClassLevel = 1,
|
||||
AchievedInfo = new Dictionary<string, object>(),
|
||||
RewardList = new List<Models.Dtos.Common.Reward>()
|
||||
});
|
||||
}
|
||||
}
|
||||
14
SVSim.EmulatedEntrypoint/Models/Dtos/Common/EmptyResponse.cs
Normal file
14
SVSim.EmulatedEntrypoint/Models/Dtos/Common/EmptyResponse.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Shared empty response. Used for endpoints whose spec mock is `"data": {}`
|
||||
/// (set_deck_redis, update_order, delete_deck_list, etc.). Includes a sentinel
|
||||
/// nullable field so MessagePack-CSharp emits a string-keyed empty map cleanly.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class EmptyResponse
|
||||
{
|
||||
[Key("_")] public object? Reserved { get; set; }
|
||||
}
|
||||
15
SVSim.EmulatedEntrypoint/Models/Dtos/Common/Reward.cs
Normal file
15
SVSim.EmulatedEntrypoint/Models/Dtos/Common/Reward.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Stub for the Reward shape (spec: common/types.ts.md#reward). Fleshed out when actual
|
||||
/// reward-granting flows land. Today's endpoints all emit empty reward_list arrays.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class Reward
|
||||
{
|
||||
[Key("type")] public int? Type { get; set; }
|
||||
[Key("value")] public long? Value { get; set; }
|
||||
[Key("num")] public int? Num { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Snake-case JSON. Only used by the import endpoint (plain JSON over HTTP, not the
|
||||
/// Unity msgpack path) so no MessagePack attributes are needed.
|
||||
/// </summary>
|
||||
public class ImportViewerRequest
|
||||
{
|
||||
[JsonPropertyName("steam_id")] public ulong SteamId { get; set; }
|
||||
|
||||
[JsonPropertyName("display_name")] public string? DisplayName { get; set; }
|
||||
[JsonPropertyName("country_code")] public string? CountryCode { get; set; }
|
||||
[JsonPropertyName("tutorial_state")] public int? TutorialState { get; set; }
|
||||
|
||||
[JsonPropertyName("selected_emblem_id")] public int? SelectedEmblemId { get; set; }
|
||||
[JsonPropertyName("selected_degree_id")] public int? SelectedDegreeId { get; set; }
|
||||
|
||||
[JsonPropertyName("currency")] public ImportCurrency? Currency { get; set; }
|
||||
|
||||
[JsonPropertyName("owned_sleeve_ids")] public List<int>? OwnedSleeveIds { get; set; }
|
||||
[JsonPropertyName("owned_emblem_ids")] public List<int>? OwnedEmblemIds { get; set; }
|
||||
[JsonPropertyName("owned_degree_ids")] public List<int>? OwnedDegreeIds { get; set; }
|
||||
[JsonPropertyName("owned_leader_skin_ids")] public List<int>? OwnedLeaderSkinIds { get; set; }
|
||||
[JsonPropertyName("owned_mypage_background_ids")] public List<int>? OwnedMyPageBackgroundIds { get; set; }
|
||||
|
||||
[JsonPropertyName("classes")] public List<ImportClassData>? Classes { get; set; }
|
||||
}
|
||||
|
||||
public class ImportCurrency
|
||||
{
|
||||
[JsonPropertyName("crystals")] public ulong? Crystals { get; set; }
|
||||
[JsonPropertyName("rupees")] public ulong? Rupees { get; set; }
|
||||
[JsonPropertyName("red_ether")] public ulong? RedEther { get; set; }
|
||||
}
|
||||
|
||||
public class ImportClassData
|
||||
{
|
||||
[JsonPropertyName("class_id")] public int ClassId { get; set; }
|
||||
[JsonPropertyName("level")] public int Level { get; set; }
|
||||
[JsonPropertyName("exp")] public int Exp { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Common request shape for endpoints scoped by deck format (`/deck/info`,
|
||||
/// `/practice/deck_list`, etc.). Spec: common/types.ts.md#deck-format-scoped-requests.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class DeckFormatRequest : BaseRequest
|
||||
{
|
||||
[Key("deck_format")] public int DeckFormat { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Deck;
|
||||
|
||||
[MessagePackObject]
|
||||
public class DeckDeleteListRequest : BaseRequest
|
||||
{
|
||||
[Key("deck_no_list")] public List<int>? DeckNoList { get; set; }
|
||||
[Key("deck_format")] public int DeckFormat { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using MessagePack;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Common;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Deck;
|
||||
|
||||
/// <summary>
|
||||
/// /deck/info — standard request is `DeckFormatRequest`. Copy-source overload adds
|
||||
/// `create_deck_format` (the format the user is creating the new deck IN). Server can
|
||||
/// ignore create_deck_format and return the standard shape; only matters for the
|
||||
/// cross-format deck-copy UI flow.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class DeckInfoRequest : DeckFormatRequest
|
||||
{
|
||||
[Key("create_deck_format")] public int? CreateDeckFormat { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Deck;
|
||||
|
||||
[MessagePackObject]
|
||||
public class DeckOrderRequest : BaseRequest
|
||||
{
|
||||
[Key("deck_order")] public List<int>? DeckOrder { get; set; }
|
||||
[Key("deck_format")] public int DeckFormat { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Deck;
|
||||
|
||||
[MessagePackObject]
|
||||
public class DeckUpdateLeaderSkinRequest : BaseRequest
|
||||
{
|
||||
[Key("deck_no")] public int DeckNo { get; set; }
|
||||
[Key("leader_skin_id")] public int LeaderSkinId { get; set; }
|
||||
[Key("deck_format")] public int DeckFormat { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Deck;
|
||||
|
||||
[MessagePackObject]
|
||||
public class DeckUpdateNameRequest : BaseRequest
|
||||
{
|
||||
[Key("deck_no")] public int DeckNo { get; set; }
|
||||
[Key("deck_name")] public string? DeckName { get; set; }
|
||||
[Key("deck_format")] public int DeckFormat { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Deck;
|
||||
|
||||
[MessagePackObject]
|
||||
public class DeckUpdateRandomLeaderSkinRequest : BaseRequest
|
||||
{
|
||||
[Key("deck_format")] public int DeckFormat { get; set; }
|
||||
[Key("deck_no")] public int DeckNo { get; set; }
|
||||
[Key("leader_skin_id_list")] public List<int>? LeaderSkinIdList { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Deck;
|
||||
|
||||
[MessagePackObject]
|
||||
public class DeckUpdateRequest : BaseRequest
|
||||
{
|
||||
[Key("deck_no")] public int DeckNo { get; set; }
|
||||
[Key("class_id")] public int ClassId { get; set; }
|
||||
[Key("leader_skin_id")] public int LeaderSkinId { get; set; }
|
||||
[Key("is_random_leader_skin")] public bool IsRandomLeaderSkin { get; set; }
|
||||
[Key("leader_skin_id_list")] public List<int>? LeaderSkinIdList { get; set; }
|
||||
[Key("sleeve_id")] public long SleeveId { get; set; }
|
||||
[Key("deck_name")] public string? DeckName { get; set; }
|
||||
|
||||
/// <summary>0 = save the deck, 1 = delete this deck slot.</summary>
|
||||
[Key("is_delete")] public int IsDelete { get; set; }
|
||||
|
||||
[Key("card_id_array")] public List<long>? CardIdArray { get; set; }
|
||||
[Key("deck_format")] public int DeckFormat { get; set; }
|
||||
|
||||
/// <summary>MyRotation rule-set id (only when deck_format = MyRotation).</summary>
|
||||
[Key("rotation_id")] public string? RotationId { get; set; }
|
||||
|
||||
/// <summary>Crossover sub-class id (only when deck_format = Crossover).</summary>
|
||||
[Key("sub_class_id")] public int? SubClassId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Deck;
|
||||
|
||||
[MessagePackObject]
|
||||
public class DeckUpdateSleeveRequest : BaseRequest
|
||||
{
|
||||
[Key("deck_no")] public int DeckNo { get; set; }
|
||||
[Key("sleeve_id")] public long SleeveId { get; set; }
|
||||
[Key("deck_format")] public int DeckFormat { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Deck;
|
||||
|
||||
[MessagePackObject]
|
||||
public class SetDeckRedisRequest : BaseRequest
|
||||
{
|
||||
[Key("deck_no")] public int DeckNo { get; set; }
|
||||
[Key("class_id")] public int ClassId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Practice;
|
||||
|
||||
[MessagePackObject]
|
||||
public class PracticeFinishRequest : BaseRequest
|
||||
{
|
||||
[Key("deck_no")] public int DeckNo { get; set; }
|
||||
[Key("is_win")] public int IsWin { get; set; }
|
||||
[Key("evolve_count")] public int EvolveCount { get; set; }
|
||||
[Key("total_turn")] public int TotalTurn { get; set; }
|
||||
[Key("enemy_class_id")] public int EnemyClassId { get; set; }
|
||||
[Key("difficulty")] public int Difficulty { get; set; }
|
||||
[Key("deck_format")] public int DeckFormat { get; set; }
|
||||
[Key("class_id")] public int ClassId { get; set; }
|
||||
|
||||
[Key("mission")] public Dictionary<string, int>? Mission { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// JSON blob — `recovery_single.json` serialized to string. Always present; not validated
|
||||
/// server-side (audit-flagged as out of scope for v1).
|
||||
/// </summary>
|
||||
[Key("recovery_data")] public string? RecoveryData { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Misspelled the same way in every solo finish endpoint — preserved on the wire.
|
||||
/// See spec note on practice-finish.md.
|
||||
/// </summary>
|
||||
[Key("prosessing_time_data")] public List<string>? ProsessingTimeData { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Admin;
|
||||
|
||||
public class ImportViewerResponse
|
||||
{
|
||||
[JsonPropertyName("viewer_id")] public long ViewerId { get; set; }
|
||||
[JsonPropertyName("short_udid")] public long ShortUdid { get; set; }
|
||||
[JsonPropertyName("was_created")] public bool WasCreated { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Deck;
|
||||
|
||||
/// <summary>
|
||||
/// Shape consumed by `DeckGroupListData(jsonData, format)` for a single-format call —
|
||||
/// the format-scoped decks land under `user_deck_list` (vs. the per-format keys used
|
||||
/// by /practice/deck_list with Format.All).
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class DeckListResponse
|
||||
{
|
||||
[Key("maintenance_card_list")] public List<long> MaintenanceCardList { get; set; } = new();
|
||||
[Key("user_deck_list")] public List<UserDeck>? UserDeckList { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using MessagePack;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Deck;
|
||||
|
||||
/// <summary>
|
||||
/// /deck/update response. Minimum-viable per spec is just {achieved_info, reward_list};
|
||||
/// the full shape also includes the refreshed deck list. We include user_deck_list to
|
||||
/// save the client a follow-up /deck/info round-trip.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class DeckUpdateResponse
|
||||
{
|
||||
[Key("user_deck_list")] public List<UserDeck>? UserDeckList { get; set; }
|
||||
[Key("achieved_info")] public Dictionary<string, object> AchievedInfo { get; set; } = new();
|
||||
[Key("reward_list")] public List<Reward> RewardList { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Deck;
|
||||
|
||||
[MessagePackObject]
|
||||
public class EmptyDeckNumberResponse
|
||||
{
|
||||
/// <summary>The next free deck slot number. 0 indicates "no slots available".</summary>
|
||||
[Key("empty_deck_num")] public int EmptyDeckNum { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Deck;
|
||||
|
||||
/// <summary>
|
||||
/// Single-deck-update response. Consumed by DeckListUtility.DeckUpdate(user_deck,
|
||||
/// format, DeckAttributeType.CustomDeck). Shape is "one UserDeck wrapped under
|
||||
/// `user_deck` key" — same for update_name, update_sleeve, update_leader_skin,
|
||||
/// update_random_leader_skin.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class SingleDeckResponse
|
||||
{
|
||||
[Key("user_deck")] public UserDeck? UserDeck { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Practice;
|
||||
|
||||
/// <summary>
|
||||
/// Same shape consumed by DeckGroupListData(jsonData, Format.All). Per-format keys are
|
||||
/// conditional — omit (don't send empty arrays) for formats the server doesn't enable.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class PracticeDeckListResponse
|
||||
{
|
||||
/// <summary>Card ids currently disabled for maintenance (client unions with global list).</summary>
|
||||
[Key("maintenance_card_list")] public List<long> MaintenanceCardList { get; set; } = new();
|
||||
|
||||
[Key("user_deck_rotation")] public List<UserDeck>? UserDeckRotation { get; set; }
|
||||
[Key("user_deck_unlimited")] public List<UserDeck>? UserDeckUnlimited { get; set; }
|
||||
|
||||
// The remaining format keys (pre_rotation, crossover, my_rotation, avatar, default_deck_list,
|
||||
// trial_deck_list, crossover_trial_deck_list, build_deck_list, user_leader_skin_setting_list)
|
||||
// are all conditional — added when those formats are enabled.
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using MessagePack;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Practice;
|
||||
|
||||
[MessagePackObject]
|
||||
public class PracticeFinishResponse
|
||||
{
|
||||
/// <summary>Class XP gained this match.</summary>
|
||||
[Key("get_class_experience")] public int GetClassExperience { get; set; }
|
||||
|
||||
/// <summary>Total accumulated class XP for the played class after this match.</summary>
|
||||
[Key("class_experience")] public int ClassExperience { get; set; }
|
||||
|
||||
/// <summary>Class level after this match (post-promotion if XP rolled over).</summary>
|
||||
[Key("class_level")] public int ClassLevel { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Missions / achievements / rewards rollup. Empty dict means "nothing accumulated"
|
||||
/// (spec: parser tolerates empty object).
|
||||
/// </summary>
|
||||
[Key("achieved_info")] public Dictionary<string, object> AchievedInfo { get; set; } = new();
|
||||
|
||||
/// <summary>Standard reward grants applied to user's inventory. Empty by default.</summary>
|
||||
[Key("reward_list")] public List<Reward> RewardList { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Practice;
|
||||
|
||||
[MessagePackObject]
|
||||
public class PracticeOpponent
|
||||
{
|
||||
/// <summary>Practice slot id (unique per entry; AI opponent identifier).</summary>
|
||||
[Key("practice_id")] public int PracticeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Text-table id resolved client-side via Data.Master.GetPracticeText(text_id).
|
||||
/// Stringified int — client calls .ToString() before lookup. Sent as string to be safe.
|
||||
/// </summary>
|
||||
[Key("text_id")] public string TextId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Class (leader) id the AI plays.</summary>
|
||||
[Key("class_id")] public int ClassId { get; set; }
|
||||
|
||||
/// <summary>Portrait / character id (which leader art the AI uses).</summary>
|
||||
[Key("chara_id")] public int CharaId { get; set; }
|
||||
|
||||
/// <summary>Title-degree id shown next to the AI's name.</summary>
|
||||
[Key("degree_id")] public int DegreeId { get; set; }
|
||||
|
||||
/// <summary>AI deck-strength tier (drives which preset deck the AI uses).</summary>
|
||||
[Key("ai_deck_level")] public int AiDeckLevel { get; set; }
|
||||
|
||||
/// <summary>AI decision-making tier.</summary>
|
||||
[Key("ai_logic_level")] public int AiLogicLevel { get; set; }
|
||||
|
||||
/// <summary>Starting HP for the AI side (often 20).</summary>
|
||||
[Key("ai_max_life")] public int AiMaxLife { get; set; } = 20;
|
||||
|
||||
/// <summary>3D battle-field asset id (string on the wire; client int.TryParse's it).</summary>
|
||||
[Key("battle3dfield_id")] public string Battle3dFieldId { get; set; } = "1";
|
||||
|
||||
/// <summary>Optional. true => entry disabled, client prepends maintenance suffix.</summary>
|
||||
[Key("is_maintenance")] public bool? IsMaintenance { get; set; }
|
||||
|
||||
/// <summary>true => entry is a special "campaign" practice (event-tied).</summary>
|
||||
[Key("is_campaign_practice")] public bool IsCampaignPractice { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Practice;
|
||||
|
||||
[MessagePackObject]
|
||||
public class PracticeStartResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Optional mission/achievement evaluation snapshot. Spec: safe to omit entirely;
|
||||
/// client tolerates absence (defensive `Keys.Contains` check). Always null in our
|
||||
/// minimal impl — we don't model missions.
|
||||
/// </summary>
|
||||
[Key("mission_parameter")] public object? MissionParameter { get; set; }
|
||||
}
|
||||
118
SVSim.UnitTests/Controllers/AdminControllerTests.cs
Normal file
118
SVSim.UnitTests/Controllers/AdminControllerTests.cs
Normal file
@@ -0,0 +1,118 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Admin;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Admin;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end coverage for <c>/admin/import_viewer</c>. The endpoint is [AllowAnonymous] so
|
||||
/// these tests don't need to seed a viewer first; the fresh-user path exercises the just-fixed
|
||||
/// nav-graph NRE inside <c>ViewerRepository.RegisterViewer</c>, and the existing-user path
|
||||
/// exercises the owned-type lookup used to dedupe by Steam id.
|
||||
/// </summary>
|
||||
public class AdminControllerTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
[Test]
|
||||
public async Task ImportViewer_fresh_user_creates_viewer_and_returns_ids()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
{
|
||||
SteamId = 76_561_198_222_333_444UL,
|
||||
DisplayName = "Fresh User",
|
||||
CountryCode = "USA",
|
||||
TutorialState = 100,
|
||||
Currency = new ImportCurrency { Crystals = 12345 }
|
||||
});
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK),
|
||||
await response.Content.ReadAsStringAsync());
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<ImportViewerResponse>(JsonOptions);
|
||||
|
||||
Assert.That(body, Is.Not.Null);
|
||||
Assert.That(body!.ViewerId, Is.GreaterThan(0), "RegisterViewer must persist and return a non-zero id.");
|
||||
Assert.That(body.WasCreated, Is.True);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var stored = await db.Viewers
|
||||
.Include(v => v.SocialAccountConnections)
|
||||
.Include(v => v.Currency)
|
||||
.Include(v => v.Info)
|
||||
.FirstAsync(v => v.Id == body.ViewerId);
|
||||
|
||||
Assert.That(stored.DisplayName, Is.EqualTo("Fresh User"));
|
||||
Assert.That(stored.Currency.Crystals, Is.EqualTo(12345UL),
|
||||
"ImportViewer should overwrite the seed-config crystal default with the requested value.");
|
||||
Assert.That(stored.Info.CountryCode, Is.EqualTo("USA"));
|
||||
Assert.That(stored.SocialAccountConnections.Count, Is.EqualTo(1));
|
||||
Assert.That(stored.SocialAccountConnections[0].AccountId, Is.EqualTo(76_561_198_222_333_444UL));
|
||||
Assert.That(stored.SocialAccountConnections[0].AccountType, Is.EqualTo(SocialAccountType.Steam));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ImportViewer_existing_user_updates_in_place()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
const ulong steamId = 76_561_198_555_666_777UL;
|
||||
long seededId = await factory.SeedViewerAsync(steamId: steamId, displayName: "Original Name");
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
{
|
||||
SteamId = steamId,
|
||||
DisplayName = "Updated Name",
|
||||
CountryCode = "JPN"
|
||||
});
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK),
|
||||
await response.Content.ReadAsStringAsync());
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<ImportViewerResponse>(JsonOptions);
|
||||
|
||||
Assert.That(body, Is.Not.Null);
|
||||
Assert.That(body!.ViewerId, Is.EqualTo(seededId),
|
||||
"Re-importing the same SteamId must reuse the existing viewer row, not create a new one.");
|
||||
Assert.That(body.WasCreated, Is.False);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var stored = await db.Viewers
|
||||
.Include(v => v.Info)
|
||||
.FirstAsync(v => v.Id == seededId);
|
||||
|
||||
Assert.That(stored.DisplayName, Is.EqualTo("Updated Name"));
|
||||
Assert.That(stored.Info.CountryCode, Is.EqualTo("JPN"));
|
||||
|
||||
var viewerCount = await db.Viewers.CountAsync(v =>
|
||||
v.SocialAccountConnections.Any(s => s.AccountType == SocialAccountType.Steam && s.AccountId == steamId));
|
||||
Assert.That(viewerCount, Is.EqualTo(1), "Owned-type dedup must not produce a second row.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ImportViewer_missing_steam_id_returns_400()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
{
|
||||
SteamId = 0,
|
||||
DisplayName = "No Steam"
|
||||
});
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||
}
|
||||
}
|
||||
117
SVSim.UnitTests/Controllers/CheckControllerTests.cs
Normal file
117
SVSim.UnitTests/Controllers/CheckControllerTests.cs
Normal file
@@ -0,0 +1,117 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Coverage for <c>/check/*</c> — the first two endpoints the client hits on boot. The
|
||||
/// SpecialTitle smoke is duplicated in RoutingSmokeTests for routing-prefix coverage; this
|
||||
/// test layers shape assertions over the deeper boot-path concern.
|
||||
/// </summary>
|
||||
public class CheckControllerTests
|
||||
{
|
||||
private const string BaseRequestJson =
|
||||
"""{"viewerId":"0","steamId":0,"steamSessionTicket":""}""";
|
||||
|
||||
private const string GameStartRequestJson =
|
||||
"""{"viewerId":"0","steamId":0,"steamSessionTicket":"","appType":0,"campaignData":"","campaignSign":"","campaignUser":0}""";
|
||||
|
||||
[Test]
|
||||
public async Task SpecialTitle_returns_default_title_id()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsync("/check/special_title",
|
||||
new StringContent(BaseRequestJson, Encoding.UTF8, "application/json"));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
Assert.That(doc.RootElement.GetProperty("titleImageId").GetString(), Is.EqualTo("0"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GameStart_with_authed_viewer_returns_spec_shape()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var response = await client.PostAsync("/check/game_start",
|
||||
new StringContent(GameStartRequestJson, Encoding.UTF8, "application/json"));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// now_tutorial_step is a STRING on the wire (prod sends "100"); client calls .ToInt().
|
||||
Assert.That(root.GetProperty("nowTutorialStep").GetString(), Is.EqualTo("100"),
|
||||
"RegisterViewer's seed-config default sets tutorial_state=100 (tutorial complete).");
|
||||
Assert.That(root.GetProperty("tosState").GetInt32(), Is.EqualTo(1));
|
||||
Assert.That(root.GetProperty("policyState").GetInt32(), Is.EqualTo(1));
|
||||
Assert.That(root.GetProperty("korAuthorityState").GetInt32(), Is.EqualTo(0));
|
||||
Assert.That(root.GetProperty("tosId").GetInt32(), Is.EqualTo(1));
|
||||
Assert.That(root.GetProperty("policyId").GetInt32(), Is.EqualTo(1));
|
||||
Assert.That(root.GetProperty("korAuthorityId").GetInt32(), Is.EqualTo(0));
|
||||
|
||||
// Prod-shape fields (not strictly read by GameStartCheckTask.Parse but sent by prod).
|
||||
Assert.That(root.GetProperty("nowViewerId").GetInt64(), Is.GreaterThan(0));
|
||||
Assert.That(root.GetProperty("nowName").GetString(), Is.Not.Empty);
|
||||
Assert.That(root.GetProperty("nowRank").ValueKind, Is.EqualTo(JsonValueKind.Object));
|
||||
|
||||
// Steam connection should round-trip into transition_account_data — all three fields
|
||||
// serialized as strings (matches prod wire shape).
|
||||
var transitions = root.GetProperty("transitionAccountData");
|
||||
Assert.That(transitions.ValueKind, Is.EqualTo(JsonValueKind.Array));
|
||||
Assert.That(transitions.GetArrayLength(), Is.EqualTo(1),
|
||||
"Seeded viewer has exactly one Steam social account connection.");
|
||||
Assert.That(transitions[0].GetProperty("socialAccountType").GetString(),
|
||||
Is.EqualTo(((int)SVSim.Database.Enums.SocialAccountType.Steam).ToString()));
|
||||
Assert.That(transitions[0].GetProperty("socialAccountId").GetString(), Is.Not.Empty);
|
||||
Assert.That(transitions[0].GetProperty("connectedViewerId").GetString(), Is.Not.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GameStart_does_not_expose_unsettable_optional_fields()
|
||||
{
|
||||
// GameStartCheckTask.Parse uses `Keys.Contains("rewrite_viewer_id")` + `.ToInt()` with
|
||||
// no null guard, and same for `account_delete_reservation_status` (presence-only check).
|
||||
// We can't omit nullable properties on the encrypted MessagePack path — the [Key]
|
||||
// formatter writes them as Nil unconditionally. So these keys must not exist on
|
||||
// GameStartResponse at all. If a future change re-adds them, this test breaks the build.
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var response = await client.PostAsync("/check/game_start",
|
||||
new StringContent(GameStartRequestJson, Encoding.UTF8, "application/json"));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
Assert.That(root.TryGetProperty("rewriteViewerId", out _), Is.False,
|
||||
"rewrite_viewer_id must NOT be present in the response — client NREs on null .ToInt().");
|
||||
Assert.That(root.TryGetProperty("accountDeleteReservationStatus", out _), Is.False,
|
||||
"account_delete_reservation_status must NOT be present — presence triggers client behavior.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GameStart_with_no_viewer_returns_401()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsync("/check/game_start",
|
||||
new StringContent(GameStartRequestJson, Encoding.UTF8, "application/json"));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
}
|
||||
409
SVSim.UnitTests/Controllers/DeckControllerTests.cs
Normal file
409
SVSim.UnitTests/Controllers/DeckControllerTests.cs
Normal file
@@ -0,0 +1,409 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Coverage for <c>/deck/*</c> — the deck-editor CRUD surface. Plain-JSON path; the
|
||||
/// camelCase'd C# property names are what tests see (see the note on Phase 6 / encrypted
|
||||
/// pipeline for the msgpack contract).
|
||||
/// </summary>
|
||||
public class DeckControllerTests
|
||||
{
|
||||
private static string DeckFormatRequestJson(Format f) =>
|
||||
$$"""{"viewerId":"0","steamId":0,"steamSessionTicket":"","deckFormat":{{(int)f}}}""";
|
||||
|
||||
private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json");
|
||||
|
||||
private static async Task<(int classId, int sleeveId, int leaderSkinId)> FetchSeededIds(SVSimTestFactory factory)
|
||||
{
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var cls = await db.Classes.Select(c => c.Id).FirstAsync();
|
||||
var sleeve = await db.Sleeves.Select(s => s.Id).FirstAsync();
|
||||
var skin = await db.LeaderSkins.Select(s => s.Id).FirstAsync();
|
||||
return (cls, sleeve, skin);
|
||||
}
|
||||
|
||||
// ---- read endpoints ----
|
||||
|
||||
[Test]
|
||||
public async Task MyList_returns_decks_for_format()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedDeckAsync(viewerId, Format.Rotation, 1, "Slot 1");
|
||||
await factory.SeedDeckAsync(viewerId, Format.Rotation, 2, "Slot 2");
|
||||
await factory.SeedDeckAsync(viewerId, Format.Unlimited, 1, "Wrong-format deck");
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var response = await client.PostAsync("/deck/my_list", JsonBody(DeckFormatRequestJson(Format.Rotation)));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var decks = doc.RootElement.GetProperty("userDeckList");
|
||||
Assert.That(decks.GetArrayLength(), Is.EqualTo(2),
|
||||
"Only Rotation-format decks should be returned for a Rotation request.");
|
||||
var names = Enumerable.Range(0, decks.GetArrayLength())
|
||||
.Select(i => decks[i].GetProperty("name").GetString())
|
||||
.ToList();
|
||||
Assert.That(names, Is.EquivalentTo(new[] { "Slot 1", "Slot 2" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Info_returns_decks_for_format()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedDeckAsync(viewerId, Format.Unlimited, 1, "Unlimited Deck");
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var response = await client.PostAsync("/deck/info", JsonBody(DeckFormatRequestJson(Format.Unlimited)));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var decks = doc.RootElement.GetProperty("userDeckList");
|
||||
Assert.That(decks.GetArrayLength(), Is.EqualTo(1));
|
||||
Assert.That(decks[0].GetProperty("name").GetString(), Is.EqualTo("Unlimited Deck"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task MyList_empty_when_viewer_has_no_decks()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var response = await client.PostAsync("/deck/my_list", JsonBody(DeckFormatRequestJson(Format.Rotation)));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var decks = doc.RootElement.GetProperty("userDeckList");
|
||||
Assert.That(decks.GetArrayLength(), Is.EqualTo(0));
|
||||
}
|
||||
|
||||
// ---- get_empty_deck_number ----
|
||||
|
||||
[Test]
|
||||
public async Task GetEmptyDeckNumber_returns_1_when_viewer_has_no_decks()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var response = await client.PostAsync("/deck/get_empty_deck_number",
|
||||
JsonBody(DeckFormatRequestJson(Format.Rotation)));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
Assert.That(doc.RootElement.GetProperty("emptyDeckNum").GetInt32(), Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetEmptyDeckNumber_returns_next_free_slot_when_slots_filled()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedDeckAsync(viewerId, Format.Rotation, 1);
|
||||
await factory.SeedDeckAsync(viewerId, Format.Rotation, 2);
|
||||
// Skip slot 3 so the algorithm should hand it back.
|
||||
await factory.SeedDeckAsync(viewerId, Format.Rotation, 4);
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var response = await client.PostAsync("/deck/get_empty_deck_number",
|
||||
JsonBody(DeckFormatRequestJson(Format.Rotation)));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
Assert.That(doc.RootElement.GetProperty("emptyDeckNum").GetInt32(), Is.EqualTo(3),
|
||||
"Algorithm must return the smallest free slot, not just one past the highest used.");
|
||||
}
|
||||
|
||||
// ---- update (create / update / delete) ----
|
||||
|
||||
[Test]
|
||||
public async Task Update_creates_new_deck_when_slot_empty()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
var (classId, sleeveId, leaderSkinId) = await FetchSeededIds(factory);
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var updateJson = $$"""
|
||||
{"viewerId":"0","steamId":0,"steamSessionTicket":"",
|
||||
"deckNo":1,"classId":{{classId}},"leaderSkinId":{{leaderSkinId}},
|
||||
"isRandomLeaderSkin":false,"sleeveId":{{sleeveId}},"deckName":"Fresh Deck",
|
||||
"isDelete":0,"deckFormat":0}
|
||||
""";
|
||||
var response = await client.PostAsync("/deck/update", JsonBody(updateJson));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var count = await db.Decks.CountAsync(d => d.Number == 1 && d.Format == Format.Rotation);
|
||||
Assert.That(count, Is.EqualTo(1), "A new deck row should have been inserted.");
|
||||
var persisted = await db.Decks.FirstAsync(d => d.Number == 1 && d.Format == Format.Rotation);
|
||||
Assert.That(persisted.Name, Is.EqualTo("Fresh Deck"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_updates_existing_deck_in_place()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedDeckAsync(viewerId, Format.Rotation, 1, name: "Original");
|
||||
var (classId, sleeveId, leaderSkinId) = await FetchSeededIds(factory);
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var updateJson = $$"""
|
||||
{"viewerId":"0","steamId":0,"steamSessionTicket":"",
|
||||
"deckNo":1,"classId":{{classId}},"leaderSkinId":{{leaderSkinId}},
|
||||
"isRandomLeaderSkin":false,"sleeveId":{{sleeveId}},"deckName":"Renamed",
|
||||
"isDelete":0,"deckFormat":0}
|
||||
""";
|
||||
await client.PostAsync("/deck/update", JsonBody(updateJson));
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var rows = await db.Decks.Where(d => d.Number == 1 && d.Format == Format.Rotation).ToListAsync();
|
||||
Assert.That(rows.Count, Is.EqualTo(1), "Update must not insert a duplicate row.");
|
||||
Assert.That(rows[0].Name, Is.EqualTo("Renamed"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_with_is_delete_1_removes_the_slot()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedDeckAsync(viewerId, Format.Rotation, 1, name: "Doomed");
|
||||
var (classId, sleeveId, leaderSkinId) = await FetchSeededIds(factory);
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var deleteJson = $$"""
|
||||
{"viewerId":"0","steamId":0,"steamSessionTicket":"",
|
||||
"deckNo":1,"classId":{{classId}},"leaderSkinId":{{leaderSkinId}},
|
||||
"isRandomLeaderSkin":false,"sleeveId":{{sleeveId}},"deckName":null,
|
||||
"isDelete":1,"deckFormat":0}
|
||||
""";
|
||||
var response = await client.PostAsync("/deck/update", JsonBody(deleteJson));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var still = await db.Decks.AnyAsync(d => d.Number == 1 && d.Format == Format.Rotation);
|
||||
Assert.That(still, Is.False, "is_delete=1 should remove the row.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_returns_refreshed_deck_list()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedDeckAsync(viewerId, Format.Rotation, 1, name: "Existing");
|
||||
var (classId, sleeveId, leaderSkinId) = await FetchSeededIds(factory);
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var updateJson = $$"""
|
||||
{"viewerId":"0","steamId":0,"steamSessionTicket":"",
|
||||
"deckNo":2,"classId":{{classId}},"leaderSkinId":{{leaderSkinId}},
|
||||
"isRandomLeaderSkin":false,"sleeveId":{{sleeveId}},"deckName":"Second",
|
||||
"isDelete":0,"deckFormat":0}
|
||||
""";
|
||||
var response = await client.PostAsync("/deck/update", JsonBody(updateJson));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var decks = doc.RootElement.GetProperty("userDeckList");
|
||||
Assert.That(decks.GetArrayLength(), Is.EqualTo(2),
|
||||
"/deck/update should hand back the full refreshed list, saving the client a follow-up.");
|
||||
var names = Enumerable.Range(0, decks.GetArrayLength())
|
||||
.Select(i => decks[i].GetProperty("name").GetString())
|
||||
.ToList();
|
||||
Assert.That(names, Is.EquivalentTo(new[] { "Existing", "Second" }));
|
||||
}
|
||||
|
||||
// ---- single-field mutations ----
|
||||
|
||||
[Test]
|
||||
public async Task UpdateName_persists_and_returns_updated_user_deck()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedDeckAsync(viewerId, Format.Rotation, 1, name: "Old Name");
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var json = """{"viewerId":"0","steamId":0,"steamSessionTicket":"","deckNo":1,"deckName":"New Name","deckFormat":0}""";
|
||||
var response = await client.PostAsync("/deck/update_name", JsonBody(json));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
Assert.That(doc.RootElement.GetProperty("userDeck").GetProperty("name").GetString(),
|
||||
Is.EqualTo("New Name"));
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var deck = await db.Decks.FirstAsync(d => d.Number == 1 && d.Format == Format.Rotation);
|
||||
Assert.That(deck.Name, Is.EqualTo("New Name"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UpdateSleeve_persists_and_returns_updated_user_deck()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedDeckAsync(viewerId, Format.Rotation, 1);
|
||||
// Pick a different sleeve than the seed default to prove the change took.
|
||||
int sleeveId;
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
sleeveId = await db.Sleeves.OrderByDescending(s => s.Id).Select(s => s.Id).FirstAsync();
|
||||
}
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var json = $$"""{"viewerId":"0","steamId":0,"steamSessionTicket":"","deckNo":1,"sleeveId":{{sleeveId}},"deckFormat":0}""";
|
||||
var response = await client.PostAsync("/deck/update_sleeve", JsonBody(json));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
Assert.That(doc.RootElement.GetProperty("userDeck").GetProperty("sleeveId").GetInt32(),
|
||||
Is.EqualTo(sleeveId));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UpdateLeaderSkin_persists_and_clears_random_flag()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedDeckAsync(viewerId, Format.Rotation, 1);
|
||||
int skinId;
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
skinId = await db.LeaderSkins.OrderByDescending(s => s.Id).Select(s => s.Id).FirstAsync();
|
||||
}
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var json = $$"""{"viewerId":"0","steamId":0,"steamSessionTicket":"","deckNo":1,"leaderSkinId":{{skinId}},"deckFormat":0}""";
|
||||
var response = await client.PostAsync("/deck/update_leader_skin", JsonBody(json));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var userDeck = doc.RootElement.GetProperty("userDeck");
|
||||
Assert.That(userDeck.GetProperty("leaderSkinId").GetInt32(), Is.EqualTo(skinId));
|
||||
Assert.That(userDeck.GetProperty("isRandomLeaderSkin").GetInt32(), Is.EqualTo(0),
|
||||
"Selecting a specific leader skin clears the random-skin flag.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UpdateRandomLeaderSkin_picks_from_pool_and_persists()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedDeckAsync(viewerId, Format.Rotation, 1);
|
||||
List<int> pool;
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
pool = await db.LeaderSkins.OrderBy(s => s.Id).Take(3).Select(s => s.Id).ToListAsync();
|
||||
}
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var json =
|
||||
$$"""{"viewerId":"0","steamId":0,"steamSessionTicket":"","deckNo":1,"deckFormat":0,"leaderSkinIdList":[{{string.Join(',', pool)}}]}""";
|
||||
var response = await client.PostAsync("/deck/update_random_leader_skin", JsonBody(json));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var userDeck = doc.RootElement.GetProperty("userDeck");
|
||||
Assert.That(pool, Contains.Item(userDeck.GetProperty("leaderSkinId").GetInt32()),
|
||||
"Chosen skin must come from the supplied pool.");
|
||||
Assert.That(userDeck.GetProperty("isRandomLeaderSkin").GetInt32(), Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UpdateRandomLeaderSkin_rejects_empty_pool_with_400()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedDeckAsync(viewerId, Format.Rotation, 1);
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var json =
|
||||
"""{"viewerId":"0","steamId":0,"steamSessionTicket":"","deckNo":1,"deckFormat":0,"leaderSkinIdList":[]}""";
|
||||
var response = await client.PostAsync("/deck/update_random_leader_skin", JsonBody(json));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UpdateOrder_returns_200()
|
||||
{
|
||||
// No persistence today (slot Number doubles as display order); just confirm the
|
||||
// endpoint round-trips so a future ordering schema doesn't silently regress 200→500.
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedDeckAsync(viewerId, Format.Rotation, 1);
|
||||
await factory.SeedDeckAsync(viewerId, Format.Rotation, 2);
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var json =
|
||||
"""{"viewerId":"0","steamId":0,"steamSessionTicket":"","deckOrder":[2,1],"deckFormat":0}""";
|
||||
var response = await client.PostAsync("/deck/update_order", JsonBody(json));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DeleteDeckList_removes_listed_slots()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedDeckAsync(viewerId, Format.Rotation, 1);
|
||||
await factory.SeedDeckAsync(viewerId, Format.Rotation, 2);
|
||||
await factory.SeedDeckAsync(viewerId, Format.Rotation, 3);
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var json =
|
||||
"""{"viewerId":"0","steamId":0,"steamSessionTicket":"","deckNoList":[1,3],"deckFormat":0}""";
|
||||
var response = await client.PostAsync("/deck/delete_deck_list", JsonBody(json));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var remaining = await db.Decks.Where(d => d.Format == Format.Rotation)
|
||||
.Select(d => d.Number).OrderBy(n => n).ToListAsync();
|
||||
Assert.That(remaining, Is.EqualTo(new[] { 2 }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SetDeckRedis_returns_200_for_authed_viewer()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var json = """{"viewerId":"0","steamId":0,"steamSessionTicket":"","deckNo":1,"classId":1}""";
|
||||
var response = await client.PostAsync("/deck/set_deck_redis", JsonBody(json));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
}
|
||||
}
|
||||
155
SVSim.UnitTests/Controllers/LoadControllerTests.cs
Normal file
155
SVSim.UnitTests/Controllers/LoadControllerTests.cs
Normal file
@@ -0,0 +1,155 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Coverage for <c>/load/index</c>. The endpoint hits the heaviest <c>.Include</c> chain in the
|
||||
/// app (<c>ViewerRepository.GetViewerByShortUdid</c>) and serializes the wide
|
||||
/// <c>IndexResponse</c> shape — first end-to-end exercise of either against a real EF provider.
|
||||
/// Shape assertions are split per test so a single regression pinpoints one named expectation.
|
||||
/// </summary>
|
||||
public class LoadControllerTests
|
||||
{
|
||||
private const string IndexRequestJson =
|
||||
"""{"viewerId":"0","steamId":0,"steamSessionTicket":"","carrier":"steam","cardMasterHash":""}""";
|
||||
|
||||
/// <summary>
|
||||
/// JSON keys (camelCased C# property names) for fields the client reads unconditionally.
|
||||
/// These come from the plain-JSON path; the wire-format snake_case keys
|
||||
/// (<c>user_rank</c>, <c>rotation_card_set_id_list</c>, ...) only apply when the
|
||||
/// encrypted msgpack pipeline is in play — see <c>EncryptedPipelineTests</c> (Phase 6).
|
||||
/// Missing any of these is a wire-shape regression in either path.
|
||||
/// </summary>
|
||||
private static readonly string[] RequiredIndexKeys =
|
||||
{
|
||||
"userTutorial", "userInfo", "userCurrency", "userItems",
|
||||
"userRotationDecks", "userUnlimitedDecks", "userMyRotationDecks",
|
||||
"userCards", "userClasses", "sleeves", "userEmblems",
|
||||
"userDegrees", "leaderSkins", "myPageBackgrounds",
|
||||
"userRankInfo", "userRankedMatches", "dailyLoginBonus", "arenaConfig",
|
||||
"redEtherOverrides", "maintenanceCards", "arenaInfos", "rankInfo",
|
||||
"classExp", "loadingTipCardExclusions", "defaultSettings",
|
||||
"unlimitedBanList", "rotationSets",
|
||||
"reprintedCards", "spotCards", "featureMaintenances",
|
||||
"specialCrystalInfos", "openBattlefieldIds", "lootBoxRegulations",
|
||||
"gatheringInfo", "userConfig", "deckFormat", "cardSetIdForResourceDlView"
|
||||
};
|
||||
|
||||
private static async Task<JsonElement> PostIndexAndReadBody(SVSimTestFactory factory, long viewerId)
|
||||
{
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var response = await client.PostAsync("/load/index",
|
||||
new StringContent(IndexRequestJson, Encoding.UTF8, "application/json"));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||
|
||||
var doc = JsonDocument.Parse(body);
|
||||
return doc.RootElement.Clone();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Index_with_minimal_viewer_returns_200()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var response = await client.PostAsync("/load/index",
|
||||
new StringContent(IndexRequestJson, Encoding.UTF8, "application/json"));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK),
|
||||
await response.Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Index_with_no_auth_header_returns_401()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsync("/load/index",
|
||||
new StringContent(IndexRequestJson, Encoding.UTF8, "application/json"));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Index_returns_all_required_keys()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
|
||||
var root = await PostIndexAndReadBody(factory, viewerId);
|
||||
|
||||
var missing = RequiredIndexKeys.Where(k => !root.TryGetProperty(k, out _)).ToList();
|
||||
Assert.That(missing, Is.Empty,
|
||||
$"Required IndexResponse keys missing: {string.Join(", ", missing)}");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Index_rank_info_is_array_not_dict()
|
||||
{
|
||||
// Guards the dict-vs-array regression that ate a previous release. Client iterates
|
||||
// user_rank by index; a dict would silently deserialize as zero entries.
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
|
||||
var root = await PostIndexAndReadBody(factory, viewerId);
|
||||
|
||||
Assert.That(root.GetProperty("userRankInfo").ValueKind, Is.EqualTo(JsonValueKind.Array));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Index_user_rank_has_five_entries()
|
||||
{
|
||||
// Hard-coded format list in LoadController.RankFormats — five entries, one per
|
||||
// deck_format discriminator. Client indexes by format value; mismatched count
|
||||
// would point the wrong format at the wrong rank slot.
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
|
||||
var root = await PostIndexAndReadBody(factory, viewerId);
|
||||
|
||||
Assert.That(root.GetProperty("userRankInfo").GetArrayLength(), Is.EqualTo(5));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Index_rotation_card_set_id_list_has_at_least_two_entries()
|
||||
{
|
||||
// LoadDetail.cs:184 unconditionally indexes [1] and [Count-1] — fewer than two
|
||||
// entries crashes the client at the home screen.
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
|
||||
var root = await PostIndexAndReadBody(factory, viewerId);
|
||||
|
||||
Assert.That(root.GetProperty("rotationSets").GetArrayLength(),
|
||||
Is.GreaterThanOrEqualTo(2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Index_when_viewer_has_no_decks_returns_empty_format_lists()
|
||||
{
|
||||
// A freshly-registered viewer has no decks of any format. The three per-format deck
|
||||
// containers must still be present and empty so the client's iteration is well-formed.
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
|
||||
var root = await PostIndexAndReadBody(factory, viewerId);
|
||||
|
||||
foreach (var key in new[] { "userRotationDecks", "userUnlimitedDecks", "userMyRotationDecks" })
|
||||
{
|
||||
var container = root.GetProperty(key);
|
||||
Assert.That(container.ValueKind, Is.EqualTo(JsonValueKind.Object),
|
||||
$"{key} should be the UserFormatDeckInfo object wrapper, not a raw array.");
|
||||
var inner = container.GetProperty("userDecks");
|
||||
Assert.That(inner.ValueKind, Is.EqualTo(JsonValueKind.Array));
|
||||
Assert.That(inner.GetArrayLength(), Is.EqualTo(0),
|
||||
$"{key}.userDecks must be an empty array for a deckless viewer, not null.");
|
||||
}
|
||||
}
|
||||
}
|
||||
120
SVSim.UnitTests/Controllers/PracticeControllerTests.cs
Normal file
120
SVSim.UnitTests/Controllers/PracticeControllerTests.cs
Normal file
@@ -0,0 +1,120 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Coverage for <c>/practice/*</c>. The solo-battle subsystem is mostly stubbed (no XP,
|
||||
/// no missions, no rewards) but the endpoints must still round-trip successfully or the
|
||||
/// solo-play UI breaks before reaching the battle screen.
|
||||
/// </summary>
|
||||
public class PracticeControllerTests
|
||||
{
|
||||
private const string BaseRequestJson =
|
||||
"""{"viewerId":"0","steamId":0,"steamSessionTicket":""}""";
|
||||
|
||||
private static string DeckFormatRequestJson(Format f) =>
|
||||
$$"""{"viewerId":"0","steamId":0,"steamSessionTicket":"","deckFormat":{{(int)f}}}""";
|
||||
|
||||
[Test]
|
||||
public async Task Info_returns_non_empty_opponent_array()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var response = await client.PostAsync("/practice/info",
|
||||
new StringContent(BaseRequestJson, Encoding.UTF8, "application/json"));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
Assert.That(doc.RootElement.ValueKind, Is.EqualTo(JsonValueKind.Array),
|
||||
"/practice/info returns a bare array (no wrapper object) per spec.");
|
||||
Assert.That(doc.RootElement.GetArrayLength(), Is.GreaterThan(0));
|
||||
Assert.That(doc.RootElement[0].GetProperty("practiceId").GetInt32(), Is.GreaterThan(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DeckList_returns_viewer_decks()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedDeckAsync(viewerId, Format.Rotation, number: 1, name: "Rotation Deck");
|
||||
await factory.SeedDeckAsync(viewerId, Format.Unlimited, number: 1, name: "Unlimited Deck");
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var response = await client.PostAsync("/practice/deck_list",
|
||||
new StringContent(DeckFormatRequestJson(Format.All), Encoding.UTF8, "application/json"));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var rotation = doc.RootElement.GetProperty("userDeckRotation");
|
||||
var unlimited = doc.RootElement.GetProperty("userDeckUnlimited");
|
||||
Assert.That(rotation.GetArrayLength(), Is.EqualTo(1));
|
||||
Assert.That(rotation[0].GetProperty("name").GetString(), Is.EqualTo("Rotation Deck"));
|
||||
Assert.That(unlimited.GetArrayLength(), Is.EqualTo(1));
|
||||
Assert.That(unlimited[0].GetProperty("name").GetString(), Is.EqualTo("Unlimited Deck"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DeckList_empty_when_viewer_has_none()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var response = await client.PostAsync("/practice/deck_list",
|
||||
new StringContent(DeckFormatRequestJson(Format.All), Encoding.UTF8, "application/json"));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
Assert.That(doc.RootElement.GetProperty("userDeckRotation").GetArrayLength(), Is.EqualTo(0));
|
||||
Assert.That(doc.RootElement.GetProperty("userDeckUnlimited").GetArrayLength(), Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Start_returns_200()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var response = await client.PostAsync("/practice/start",
|
||||
new StringContent(BaseRequestJson, Encoding.UTF8, "application/json"));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Finish_accepts_any_recovery_data_returns_zero_xp()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
// recoveryData is an opaque JSON blob serialized to string by the client; the server
|
||||
// is supposed to accept it without validation. Anything goes.
|
||||
var finishJson =
|
||||
"""{"viewerId":"0","steamId":0,"steamSessionTicket":"","deckNo":1,"isWin":1,"evolveCount":2,"totalTurn":5,"enemyClassId":3,"difficulty":1,"deckFormat":0,"classId":1,"recoveryData":"{\"opaque\":\"blob\"}"}""";
|
||||
|
||||
var response = await client.PostAsync("/practice/finish",
|
||||
new StringContent(finishJson, Encoding.UTF8, "application/json"));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
Assert.That(doc.RootElement.GetProperty("getClassExperience").GetInt32(), Is.EqualTo(0));
|
||||
Assert.That(doc.RootElement.GetProperty("classExperience").GetInt32(), Is.EqualTo(0));
|
||||
Assert.That(doc.RootElement.GetProperty("rewardList").GetArrayLength(), Is.EqualTo(0));
|
||||
}
|
||||
}
|
||||
182
SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs
Normal file
182
SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs
Normal file
@@ -0,0 +1,182 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Repositories.Deck;
|
||||
using SVSim.Database.Repositories.Viewer;
|
||||
using SVSim.EmulatedEntrypoint;
|
||||
using SVSim.EmulatedEntrypoint.Security.SteamSessionAuthentication;
|
||||
|
||||
namespace SVSim.UnitTests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Test host for the EmulatedEntrypoint app. Each instance opens a private SQLite in-memory
|
||||
/// database, swaps the production DbContext + Steam auth handler for SQLite-friendly +
|
||||
/// header-driven test versions, and exposes a <see cref="SeedViewerAsync"/> helper for tests
|
||||
/// to create realistic viewer rows.
|
||||
/// </summary>
|
||||
internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
private long _nextSeededShortUdid = 400_000_001;
|
||||
|
||||
public SVSimTestFactory()
|
||||
{
|
||||
// SQLite :memory: lives only as long as a connection is open — keep ours open for the
|
||||
// factory's lifetime so the DbContext can reattach to the same DB across scopes.
|
||||
_connection = new SqliteConnection("DataSource=:memory:");
|
||||
_connection.Open();
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
// Tell Program.cs we're in tests so it skips UpdateDatabase() — the Postgres-targeted
|
||||
// migrations would fail against SQLite. We call EnsureCreated below instead.
|
||||
builder.UseEnvironment("Testing");
|
||||
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
ReplaceDbContext(services);
|
||||
ReplaceAuthHandler(services);
|
||||
});
|
||||
}
|
||||
|
||||
protected override IHost CreateHost(IHostBuilder builder)
|
||||
{
|
||||
var host = base.CreateHost(builder);
|
||||
|
||||
using var scope = host.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
db.Database.EnsureCreated();
|
||||
|
||||
return host;
|
||||
}
|
||||
|
||||
private void ReplaceDbContext(IServiceCollection services)
|
||||
{
|
||||
// Production registered DbContextOptions<SVSimDbContext> with the Npgsql provider; tear
|
||||
// out every related descriptor so AddDbContext below installs a clean SQLite-backed one.
|
||||
foreach (var descriptor in services
|
||||
.Where(d => d.ServiceType == typeof(DbContextOptions<SVSimDbContext>)
|
||||
|| d.ServiceType == typeof(DbContextOptions)
|
||||
|| d.ServiceType == typeof(SVSimDbContext))
|
||||
.ToList())
|
||||
{
|
||||
services.Remove(descriptor);
|
||||
}
|
||||
|
||||
services.AddDbContext<SVSimDbContext>(opt =>
|
||||
{
|
||||
opt.UseSqlite(_connection);
|
||||
opt.ReplaceService<Microsoft.EntityFrameworkCore.Infrastructure.IModelCustomizer, SqliteFriendlyModelCustomizer>();
|
||||
});
|
||||
}
|
||||
|
||||
private static void ReplaceAuthHandler(IServiceCollection services)
|
||||
{
|
||||
// Production Program.cs registered SteamSessionAuthenticationHandler under the
|
||||
// "SteamAuthentication" scheme. Drop that scheme from BOTH the SchemeMap and the
|
||||
// parallel Schemes list (AddScheme writes to both — and the provider iterates the
|
||||
// list, not the map, so leaving the old builder behind throws "Scheme already exists"
|
||||
// when it re-adds during provider construction).
|
||||
services.AddTransient<TestAuthHandler>();
|
||||
services.PostConfigure<AuthenticationOptions>(opt =>
|
||||
{
|
||||
opt.SchemeMap.Remove(SteamAuthenticationConstants.SchemeName, out _);
|
||||
var schemesList = (IList<AuthenticationSchemeBuilder>)opt.Schemes;
|
||||
foreach (var stale in schemesList
|
||||
.Where(s => s.Name == SteamAuthenticationConstants.SchemeName)
|
||||
.ToList())
|
||||
{
|
||||
schemesList.Remove(stale);
|
||||
}
|
||||
|
||||
opt.AddScheme(SteamAuthenticationConstants.SchemeName, b =>
|
||||
{
|
||||
b.HandlerType = typeof(TestAuthHandler);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a fully-formed viewer via the real <see cref="IViewerRepository.RegisterViewer"/>
|
||||
/// path (so the test exercises the same nav-graph wiring real users hit). The viewer's
|
||||
/// <c>ShortUdid</c> is overwritten to a unique non-zero value because the Postgres sequence
|
||||
/// is disabled on SQLite — without this every test viewer collides on 0.
|
||||
/// </summary>
|
||||
public async Task<long> SeedViewerAsync(
|
||||
ulong steamId = 76_561_198_000_000_001UL,
|
||||
string displayName = "Test Viewer")
|
||||
{
|
||||
long viewerId;
|
||||
long shortUdid;
|
||||
|
||||
using (var scope = Services.CreateScope())
|
||||
{
|
||||
var repo = scope.ServiceProvider.GetRequiredService<IViewerRepository>();
|
||||
var v = await repo.RegisterViewer(displayName, SocialAccountType.Steam, steamId);
|
||||
viewerId = v.Id;
|
||||
shortUdid = Interlocked.Increment(ref _nextSeededShortUdid);
|
||||
}
|
||||
|
||||
// Second scope: assign a real ShortUdid so claim-based lookups in tests have something
|
||||
// to find (and so per-viewer ShortUdids don't collide across SeedViewerAsync calls).
|
||||
using (var scope = Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var v = await db.Viewers.FirstAsync(x => x.Id == viewerId);
|
||||
v.ShortUdid = shortUdid;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return viewerId;
|
||||
}
|
||||
|
||||
/// <summary>Convenience: bake the X-Test-Viewer-Id header into a fresh client.</summary>
|
||||
public HttpClient CreateAuthenticatedClient(long viewerId)
|
||||
{
|
||||
var client = CreateClient();
|
||||
client.DefaultRequestHeaders.Add(TestAuthHandler.ViewerIdHeader, viewerId.ToString());
|
||||
return client;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inserts a deck for the viewer via the real <see cref="IDeckRepository.UpsertDeck"/>
|
||||
/// path. Picks the first seeded class/sleeve/leader-skin from the master tables; tests
|
||||
/// that need specific ids should hit the DB directly.
|
||||
/// </summary>
|
||||
public async Task SeedDeckAsync(long viewerId, Format format, int number, string name = "Test Deck")
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<IDeckRepository>();
|
||||
|
||||
var cls = await db.Classes.FirstAsync();
|
||||
var sleeve = await db.Sleeves.FirstAsync();
|
||||
var skin = await db.LeaderSkins.FirstAsync();
|
||||
|
||||
await repo.UpsertDeck(viewerId, format, number, d =>
|
||||
{
|
||||
d.Name = name;
|
||||
d.Class = cls;
|
||||
d.Sleeve = sleeve;
|
||||
d.LeaderSkin = skin;
|
||||
});
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
if (disposing)
|
||||
{
|
||||
_connection.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.ValueGeneration;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.UnitTests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the default <see cref="ModelCustomizer"/> in tests. After the normal
|
||||
/// <c>OnModelCreating</c> runs, strips the Postgres sequence the production model declares
|
||||
/// for <c>Viewer.ShortUdid</c> so EnsureCreated can build the schema against SQLite (which
|
||||
/// has no sequence support).
|
||||
/// </summary>
|
||||
internal class SqliteFriendlyModelCustomizer : ModelCustomizer
|
||||
{
|
||||
public SqliteFriendlyModelCustomizer(ModelCustomizerDependencies dependencies) : base(dependencies)
|
||||
{
|
||||
}
|
||||
|
||||
public override void Customize(ModelBuilder modelBuilder, DbContext context)
|
||||
{
|
||||
base.Customize(modelBuilder, context);
|
||||
|
||||
modelBuilder.Model.RemoveSequence("ShortUdidSequence");
|
||||
|
||||
var shortUdidProperty = modelBuilder.Entity<Viewer>().Property(v => v.ShortUdid).Metadata;
|
||||
shortUdidProperty.RemoveAnnotation("Relational:DefaultValueSql");
|
||||
shortUdidProperty.ValueGenerated = ValueGenerated.Never;
|
||||
|
||||
AssignClientSideKeyGenerators(modelBuilder.Model);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owned-collection shadow PKs are <c>ValueGenerated.OnAdd</c> with the production model
|
||||
/// expecting the database to auto-fill (Postgres IDENTITY). On SQLite a composite-PK column
|
||||
/// is not a ROWID alias, so the DB can't auto-fill it and we get NOT NULL violations. Walk
|
||||
/// every owned entity and swap any auto-add primary-key property to use an in-process
|
||||
/// counter instead.
|
||||
/// </summary>
|
||||
private static void AssignClientSideKeyGenerators(IMutableModel model)
|
||||
{
|
||||
foreach (var entityType in model.GetEntityTypes())
|
||||
{
|
||||
if (!entityType.IsOwned()) continue;
|
||||
|
||||
foreach (var key in entityType.GetKeys())
|
||||
{
|
||||
foreach (var property in key.Properties)
|
||||
{
|
||||
if (property.ValueGenerated != ValueGenerated.OnAdd) continue;
|
||||
if (property.ClrType != typeof(int) && property.ClrType != typeof(long)) continue;
|
||||
|
||||
property.SetValueGeneratorFactory((_, _) =>
|
||||
property.ClrType == typeof(int)
|
||||
? (ValueGenerator)new MonotonicIntValueGenerator()
|
||||
: new MonotonicLongValueGenerator());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class MonotonicIntValueGenerator : ValueGenerator<int>
|
||||
{
|
||||
private static int _current;
|
||||
public override bool GeneratesTemporaryValues => false;
|
||||
public override int Next(EntityEntry entry) => Interlocked.Increment(ref _current);
|
||||
}
|
||||
|
||||
internal sealed class MonotonicLongValueGenerator : ValueGenerator<long>
|
||||
{
|
||||
private static long _current;
|
||||
public override bool GeneratesTemporaryValues => false;
|
||||
public override long Next(EntityEntry entry) => Interlocked.Increment(ref _current);
|
||||
}
|
||||
75
SVSim.UnitTests/Infrastructure/TestAuthHandler.cs
Normal file
75
SVSim.UnitTests/Infrastructure/TestAuthHandler.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.EmulatedEntrypoint.Constants;
|
||||
using SVSim.EmulatedEntrypoint.Extensions;
|
||||
using SVSim.EmulatedEntrypoint.Security.SteamSessionAuthentication;
|
||||
|
||||
namespace SVSim.UnitTests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Replaces <see cref="SteamSessionAuthenticationHandler"/> in tests. Reads the viewer id from
|
||||
/// the <c>X-Test-Viewer-Id</c> header, looks the viewer up, and builds the same claim set the
|
||||
/// real handler would. Registered under the same scheme name so controller <c>[Authorize]</c>
|
||||
/// attributes resolve without modification.
|
||||
/// </summary>
|
||||
internal class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public const string ViewerIdHeader = "X-Test-Viewer-Id";
|
||||
|
||||
private readonly SVSimDbContext _dbContext;
|
||||
|
||||
public TestAuthHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
SVSimDbContext dbContext)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
}
|
||||
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
if (!Request.Headers.TryGetValue(ViewerIdHeader, out var raw))
|
||||
{
|
||||
return AuthenticateResult.NoResult();
|
||||
}
|
||||
|
||||
if (!long.TryParse(raw.ToString(), out long viewerId))
|
||||
{
|
||||
return AuthenticateResult.Fail($"{ViewerIdHeader} is not a valid long.");
|
||||
}
|
||||
|
||||
Viewer? viewer = await _dbContext.Viewers
|
||||
.AsNoTracking()
|
||||
.Include(v => v.SocialAccountConnections)
|
||||
.FirstOrDefaultAsync(v => v.Id == viewerId);
|
||||
|
||||
if (viewer is null)
|
||||
{
|
||||
return AuthenticateResult.Fail($"No viewer with id {viewerId} — test forgot to seed.");
|
||||
}
|
||||
|
||||
Context.SetViewer(viewer);
|
||||
|
||||
var identity = new ClaimsIdentity(SteamAuthenticationConstants.SchemeName);
|
||||
identity.AddClaim(new Claim(ClaimTypes.Name, viewer.DisplayName));
|
||||
identity.AddClaim(new Claim(ShadowverseClaimTypes.ShortUdidClaim, viewer.ShortUdid.ToString()));
|
||||
identity.AddClaim(new Claim(ShadowverseClaimTypes.ViewerIdClaim, viewer.Id.ToString()));
|
||||
|
||||
var steamConnection = viewer.SocialAccountConnections.FirstOrDefault();
|
||||
if (steamConnection is not null)
|
||||
{
|
||||
identity.AddClaim(new Claim(SteamAuthenticationConstants.SteamIdClaim, steamConnection.AccountId.ToString()));
|
||||
}
|
||||
|
||||
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), SteamAuthenticationConstants.SchemeName);
|
||||
return AuthenticateResult.Success(ticket);
|
||||
}
|
||||
}
|
||||
74
SVSim.UnitTests/Repositories/ViewerRepositoryTests.cs
Normal file
74
SVSim.UnitTests/Repositories/ViewerRepositoryTests.cs
Normal file
@@ -0,0 +1,74 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Repositories.Viewer;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Direct tests against <see cref="ViewerRepository"/>. The owned-type lookup in
|
||||
/// <see cref="ViewerRepository.GetViewerBySocialConnection"/> previously used
|
||||
/// <c>_dbContext.Set<SocialAccountConnection>()</c> which EF couldn't translate (owned
|
||||
/// types aren't queryable as a root). This test would have caught the regression.
|
||||
/// </summary>
|
||||
public class ViewerRepositoryTests
|
||||
{
|
||||
[Test]
|
||||
public async Task GetViewerBySocialConnection_returns_viewer_when_steam_id_matches()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
const ulong steamId = 76_561_198_111_222_333UL;
|
||||
|
||||
long expectedViewerId = await factory.SeedViewerAsync(steamId: steamId, displayName: "Owner");
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<IViewerRepository>();
|
||||
|
||||
Viewer? found = await repo.GetViewerBySocialConnection(SocialAccountType.Steam, steamId);
|
||||
|
||||
Assert.That(found, Is.Not.Null, "Expected to find the seeded viewer by Steam social connection.");
|
||||
Assert.That(found!.Id, Is.EqualTo(expectedViewerId));
|
||||
Assert.That(found.DisplayName, Is.EqualTo("Owner"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetViewerBySocialConnection_returns_null_when_steam_id_does_not_match()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
await factory.SeedViewerAsync(steamId: 76_561_198_111_222_333UL);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<IViewerRepository>();
|
||||
|
||||
Viewer? found = await repo.GetViewerBySocialConnection(SocialAccountType.Steam, 76_561_198_999_999_999UL);
|
||||
|
||||
Assert.That(found, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RegisterViewer_grants_default_leader_skins_to_classes()
|
||||
{
|
||||
// Guards the just-fixed nav-graph NRE — RegisterViewer iterates ClassEntry.LeaderSkins
|
||||
// and needs the .Include to populate them. If the include is lost, this throws inside
|
||||
// SeedViewerAsync rather than reaching the assertion.
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
|
||||
Viewer viewer = await db.Viewers
|
||||
.Include(v => v.Classes).ThenInclude(c => c.LeaderSkin)
|
||||
.Include(v => v.LeaderSkins)
|
||||
.FirstAsync(v => v.Id == viewerId);
|
||||
|
||||
Assert.That(viewer.Classes, Is.Not.Empty, "RegisterViewer should populate Classes from seed data.");
|
||||
Assert.That(viewer.Classes.Select(c => c.LeaderSkin).All(s => s is not null), Is.True,
|
||||
"Every class should have a LeaderSkin assigned (placeholder or real).");
|
||||
Assert.That(viewer.LeaderSkins, Is.Not.Empty,
|
||||
"Viewer should own at least one leader skin from class defaults.");
|
||||
}
|
||||
}
|
||||
114
SVSim.UnitTests/RoutingSmokeTests.cs
Normal file
114
SVSim.UnitTests/RoutingSmokeTests.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SVSim.Database;
|
||||
using SVSim.EmulatedEntrypoint;
|
||||
|
||||
namespace SVSim.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the routing-prefix fix (audit step 5) actually exposes endpoints at the URLs the
|
||||
/// client calls (no `api/` prefix). Posts plain JSON without UnityPlayer UA so the
|
||||
/// translation middleware bypasses and we test routing in isolation.
|
||||
/// </summary>
|
||||
public class RoutingSmokeTests
|
||||
{
|
||||
private sealed class TestFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder)
|
||||
{
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<SVSimDbContext>));
|
||||
if (descriptor != null) services.Remove(descriptor);
|
||||
services.AddDbContext<SVSimDbContext>(opt => opt.UseInMemoryDatabase("RoutingSmoke"));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private const string ValidBaseRequestJson =
|
||||
"""{"viewerId":"0","steamId":0,"steamSessionTicket":""}""";
|
||||
|
||||
[Test]
|
||||
public async Task CheckSpecialTitle_resolves_to_CheckController()
|
||||
{
|
||||
using var factory = new TestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsync("/check/special_title",
|
||||
new StringContent(ValidBaseRequestJson, Encoding.UTF8, "application/json"));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK),
|
||||
$"Expected 200 OK at /check/special_title, got {response.StatusCode}. " +
|
||||
"If 404, the routing prefix fix (audit step 5) didn't take.");
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
// Plain-JSON path uses camelCase (System.Text.Json default); MessagePack [Key] only applies
|
||||
// to the Unity-UA encrypted path through ShadowverseTranslationMiddleware.
|
||||
Assert.That(body, Does.Contain("\"titleImageId\":\"0\""),
|
||||
"SpecialTitleCheck should return the built-in title id \"0\".");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ImportViewer_route_resolves()
|
||||
{
|
||||
// /admin/import_viewer is AllowAnonymous so the route should at least be reachable
|
||||
// (probably returns 400 for missing steam_id with our empty BaseRequest body; we only
|
||||
// assert routing not deep behavior).
|
||||
using var factory = new TestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsync("/admin/import_viewer",
|
||||
new StringContent(ValidBaseRequestJson, Encoding.UTF8, "application/json"));
|
||||
|
||||
Assert.That(response.StatusCode, Is.Not.EqualTo(HttpStatusCode.NotFound),
|
||||
"/admin/import_viewer didn't resolve to a controller — route registration broken.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ApiPrefixedRoute_returns_404()
|
||||
{
|
||||
// The OLD broken path should now 404 — proves we dropped the `api/` prefix cleanly.
|
||||
using var factory = new TestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsync("/api/check/special_title",
|
||||
new StringContent(ValidBaseRequestJson, Encoding.UTF8, "application/json"));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
|
||||
}
|
||||
|
||||
// Authenticated endpoints — we don't set up Steam auth in tests, so we just assert the
|
||||
// route resolves (anything other than 404). Auth-flow integration tests are a separate
|
||||
// problem — see PLAN.md status-log open item on body re-read.
|
||||
|
||||
[TestCase("/practice/info")]
|
||||
[TestCase("/practice/deck_list")]
|
||||
[TestCase("/practice/start")]
|
||||
[TestCase("/practice/finish")]
|
||||
[TestCase("/deck/my_list")]
|
||||
[TestCase("/deck/info")]
|
||||
[TestCase("/deck/update")]
|
||||
[TestCase("/deck/update_name")]
|
||||
[TestCase("/deck/update_sleeve")]
|
||||
[TestCase("/deck/update_leader_skin")]
|
||||
[TestCase("/deck/update_random_leader_skin")]
|
||||
[TestCase("/deck/update_order")]
|
||||
[TestCase("/deck/delete_deck_list")]
|
||||
[TestCase("/deck/get_empty_deck_number")]
|
||||
[TestCase("/deck/set_deck_redis")]
|
||||
public async Task Authenticated_route_resolves(string path)
|
||||
{
|
||||
using var factory = new TestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsync(path,
|
||||
new StringContent(ValidBaseRequestJson, Encoding.UTF8, "application/json"));
|
||||
|
||||
Assert.That(response.StatusCode, Is.Not.EqualTo(HttpStatusCode.NotFound),
|
||||
$"{path} didn't resolve to a controller — route registration broken.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user