fix(import): tolerate numeric my_rotation_id; skip empty deck slots
A real /load/index dump emits my_rotation_id as a bare number (0) for unset MyRotation slots, which 400'd against the string? DTO field (AllowReadingFromString only covers string->number). FlexibleStringConverter accepts either form. Also skip empty deck slots (no cards) on import — a dump carries every slot, mostly empty placeholders the client manages itself. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -206,6 +206,12 @@ public class AdminController : SVSimController
|
|||||||
|
|
||||||
foreach (var d in request.Decks)
|
foreach (var d in request.Decks)
|
||||||
{
|
{
|
||||||
|
// A /load/index dump carries every deck slot, most of them empty placeholders
|
||||||
|
// (no cards). Skip them: the client manages empty slots itself (it's why the old
|
||||||
|
// default-deck cloning was removed), and importing empty MyRotation slots would
|
||||||
|
// otherwise persist decks with a bogus rotation id.
|
||||||
|
if ((d.CardIdArray?.Count ?? 0) == 0) continue;
|
||||||
|
|
||||||
Format format;
|
Format format;
|
||||||
try { format = FormatExtensions.FromApi(d.DeckFormat); }
|
try { format = FormatExtensions.FromApi(d.DeckFormat); }
|
||||||
catch (ArgumentOutOfRangeException) { continue; } // skip unsupported wire format
|
catch (ArgumentOutOfRangeException) { continue; } // skip unsupported wire format
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads a JSON string OR number as a nullable string, tolerating prod's polymorphic id fields.
|
||||||
|
/// <c>rotation_id</c> on a /load/index <c>UserDeck</c> is a numeric string ("10008") for real
|
||||||
|
/// MyRotation decks but a bare number (<c>0</c>) for unset slots — and the global
|
||||||
|
/// <c>AllowReadingFromString</c> only covers the string→number direction, not number→string, so a
|
||||||
|
/// plain <c>string?</c> property 400s on the numeric form. Null stays null; numbers serialize via
|
||||||
|
/// invariant culture so a captured <c>0</c> round-trips to <c>"0"</c>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FlexibleStringConverter : JsonConverter<string?>
|
||||||
|
{
|
||||||
|
public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
|
||||||
|
reader.TokenType switch
|
||||||
|
{
|
||||||
|
JsonTokenType.Null => null,
|
||||||
|
JsonTokenType.String => reader.GetString(),
|
||||||
|
JsonTokenType.Number when reader.TryGetInt64(out var n) => n.ToString(CultureInfo.InvariantCulture),
|
||||||
|
JsonTokenType.Number => reader.GetDouble().ToString(CultureInfo.InvariantCulture),
|
||||||
|
_ => throw new JsonException($"Unexpected token {reader.TokenType} for a string-or-number field.")
|
||||||
|
};
|
||||||
|
|
||||||
|
public override void Write(Utf8JsonWriter writer, string? value, JsonSerializerOptions options) =>
|
||||||
|
writer.WriteStringValue(value);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
|
||||||
|
|
||||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Admin;
|
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Admin;
|
||||||
|
|
||||||
@@ -44,7 +45,11 @@ public class ImportDeck
|
|||||||
[JsonPropertyName("sleeve_id")] public long? SleeveId { get; set; }
|
[JsonPropertyName("sleeve_id")] public long? SleeveId { get; set; }
|
||||||
[JsonPropertyName("leader_skin_id")] public int? LeaderSkinId { get; set; }
|
[JsonPropertyName("leader_skin_id")] public int? LeaderSkinId { get; set; }
|
||||||
[JsonPropertyName("is_random_leader_skin")] public int? IsRandomLeaderSkin { get; set; }
|
[JsonPropertyName("is_random_leader_skin")] public int? IsRandomLeaderSkin { get; set; }
|
||||||
[JsonPropertyName("my_rotation_id")] public string? MyRotationId { get; set; }
|
// Prod emits rotation_id as a numeric string ("10008") for real MyRotation decks but a bare
|
||||||
|
// number (0) for unset slots; FlexibleStringConverter accepts either (a plain string? 400s on
|
||||||
|
// the numeric form because AllowReadingFromString only covers string→number).
|
||||||
|
[JsonPropertyName("my_rotation_id")] [JsonConverter(typeof(FlexibleStringConverter))]
|
||||||
|
public string? MyRotationId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ImportCurrency
|
public class ImportCurrency
|
||||||
|
|||||||
@@ -400,4 +400,54 @@ public class AdminControllerTests
|
|||||||
Assert.That(stored.Decks.Any(d => d.Name == "Wire Deck" && d.Format == Format.Rotation), Is.True,
|
Assert.That(stored.Decks.Any(d => d.Name == "Wire Deck" && d.Format == Format.Rotation), Is.True,
|
||||||
"decks snake_case keys must bind (deck_format/deck_no/class_id/card_id_array/...).");
|
"decks snake_case keys must bind (deck_format/deck_no/class_id/card_id_array/...).");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ImportViewer_tolerates_numeric_my_rotation_id_and_skips_empty_decks()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
const ulong steamId = 76_561_198_111_222_341UL;
|
||||||
|
long viewerId = await factory.SeedViewerAsync(steamId: steamId);
|
||||||
|
|
||||||
|
int classId, leaderSkinId; long sleeveId;
|
||||||
|
using (var scope = factory.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
classId = (await db.Classes.FirstAsync()).Id;
|
||||||
|
sleeveId = (await db.Sleeves.FirstAsync()).Id;
|
||||||
|
leaderSkinId = (await db.LeaderSkins.FirstAsync()).Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors a real prod dump: empty MyRotation slots carry "my_rotation_id": 0 (a NUMBER,
|
||||||
|
// not a string), and dozens of empty slots accompany the few real decks.
|
||||||
|
string json = $$"""
|
||||||
|
{
|
||||||
|
"steam_id": {{steamId}},
|
||||||
|
"decks": [
|
||||||
|
{ "deck_format": 2, "deck_no": 1, "deck_name": "Real", "class_id": {{classId}},
|
||||||
|
"sleeve_id": {{sleeveId}}, "leader_skin_id": {{leaderSkinId}},
|
||||||
|
"is_random_leader_skin": 0, "card_id_array": [10001001, 10001002] },
|
||||||
|
{ "deck_format": 5, "deck_no": 1, "deck_name": "", "class_id": 0,
|
||||||
|
"sleeve_id": 3000011, "leader_skin_id": 0, "is_random_leader_skin": 0,
|
||||||
|
"my_rotation_id": 0, "card_id_array": [] },
|
||||||
|
{ "deck_format": 1, "deck_no": 3, "deck_name": "", "class_id": 1,
|
||||||
|
"sleeve_id": 3000011, "leader_skin_id": 0, "is_random_leader_skin": 0,
|
||||||
|
"card_id_array": [] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
using var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
|
||||||
|
var response = await client.PostAsync("/admin/import_viewer", content);
|
||||||
|
|
||||||
|
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK),
|
||||||
|
await response.Content.ReadAsStringAsync());
|
||||||
|
|
||||||
|
using var scope2 = factory.Services.CreateScope();
|
||||||
|
var db2 = scope2.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
var stored = await db2.Viewers.Include(v => v.Decks).FirstAsync(v => v.Id == viewerId);
|
||||||
|
Assert.That(stored.Decks.Count, Is.EqualTo(1),
|
||||||
|
"Empty deck slots must be skipped; only the real (non-empty) deck imports.");
|
||||||
|
Assert.That(stored.Decks.Single().Name, Is.EqualTo("Real"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user