refactor(bootstrap): migrate payment items to seed file
Lifts ImportPaymentItems out of GlobalsImporter into a dedicated PaymentItemImporter driven by Data/seeds/payment-items.json. Wired into Program.cs and SVSimTestFactory.SeedGlobalsAsync after PracticeOpponentImporter. Drops the prod-capture file in favor of the extractor pipeline. Canonical 4-test suite (basic, idempotent, leave-untouched, skip-zero) keeps the dict-in-sync upsert pattern Task 2 established. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,201 +0,0 @@
|
||||
{
|
||||
"data_headers": {
|
||||
"sid": "ac631c29b5f5d07ed5fb6712ad8623c31779553960",
|
||||
"short_udid": 411054851,
|
||||
"viewer_id": 906243102,
|
||||
"servertime": 1779553960,
|
||||
"result_code": 1
|
||||
},
|
||||
"data": {
|
||||
"10011": {
|
||||
"record_id": "21",
|
||||
"id": "8",
|
||||
"store_product_id": "10011",
|
||||
"name": "60-crystal set",
|
||||
"text": "Purchase 60 Crystals",
|
||||
"price": "0.99",
|
||||
"charge_crystal_num": "60",
|
||||
"free_crystal_num": "0",
|
||||
"purchase_limit": "999999999",
|
||||
"special_shop_flag": "0",
|
||||
"image_name": "thumbnail_crystal",
|
||||
"start_time": "2022-10-05 15:00:00",
|
||||
"end_time": "2030-03-01 14:59:59",
|
||||
"remaining_time": "0",
|
||||
"is_resale_product": "0",
|
||||
"resale_start_date": "",
|
||||
"purchase_num_current": 0
|
||||
},
|
||||
"30011": {
|
||||
"record_id": "26",
|
||||
"id": "10",
|
||||
"store_product_id": "30011",
|
||||
"name": "670-crystal set",
|
||||
"text": "Purchase 670 Crystals",
|
||||
"price": "10.99",
|
||||
"charge_crystal_num": "670",
|
||||
"free_crystal_num": "0",
|
||||
"purchase_limit": "999999999",
|
||||
"special_shop_flag": "0",
|
||||
"image_name": "thumbnail_crystal",
|
||||
"start_time": "2022-10-05 15:00:00",
|
||||
"end_time": "2030-03-01 14:59:59",
|
||||
"remaining_time": "0",
|
||||
"is_resale_product": "0",
|
||||
"resale_start_date": "",
|
||||
"purchase_num_current": 0
|
||||
},
|
||||
"40000": {
|
||||
"record_id": "27",
|
||||
"id": "4",
|
||||
"store_product_id": "40000",
|
||||
"name": "1200-crystal set",
|
||||
"text": "Purchase 1200 Crystals",
|
||||
"price": "20.99",
|
||||
"charge_crystal_num": "1200",
|
||||
"free_crystal_num": "0",
|
||||
"purchase_limit": "999999999",
|
||||
"special_shop_flag": "0",
|
||||
"image_name": "thumbnail_crystal",
|
||||
"start_time": "2015-03-01 15:00:00",
|
||||
"end_time": "2030-03-01 14:59:59",
|
||||
"remaining_time": "0",
|
||||
"is_resale_product": "0",
|
||||
"resale_start_date": "",
|
||||
"purchase_num_current": 0
|
||||
},
|
||||
"50000": {
|
||||
"record_id": "28",
|
||||
"id": "5",
|
||||
"store_product_id": "50000",
|
||||
"name": "2400-crystal set",
|
||||
"text": "Purchase 2400 Crystals",
|
||||
"price": "39.99",
|
||||
"charge_crystal_num": "2400",
|
||||
"free_crystal_num": "0",
|
||||
"purchase_limit": "999999999",
|
||||
"special_shop_flag": "0",
|
||||
"image_name": "thumbnail_crystal",
|
||||
"start_time": "2015-03-01 15:00:00",
|
||||
"end_time": "2030-03-01 14:59:59",
|
||||
"remaining_time": "0",
|
||||
"is_resale_product": "0",
|
||||
"resale_start_date": "",
|
||||
"purchase_num_current": 0
|
||||
},
|
||||
"60000": {
|
||||
"record_id": "29",
|
||||
"id": "6",
|
||||
"store_product_id": "60000",
|
||||
"name": "5000-crystal set",
|
||||
"text": "Purchase 5000 Crystals",
|
||||
"price": "79.99",
|
||||
"charge_crystal_num": "5000",
|
||||
"free_crystal_num": "0",
|
||||
"purchase_limit": "999999999",
|
||||
"special_shop_flag": "0",
|
||||
"image_name": "thumbnail_crystal",
|
||||
"start_time": "2015-03-01 15:00:00",
|
||||
"end_time": "2030-03-01 14:59:59",
|
||||
"remaining_time": "0",
|
||||
"is_resale_product": "0",
|
||||
"resale_start_date": "",
|
||||
"purchase_num_current": 0
|
||||
},
|
||||
"70011": {
|
||||
"record_id": "24",
|
||||
"id": "9",
|
||||
"store_product_id": "70011",
|
||||
"name": "350-crystal set",
|
||||
"text": "Purchase 350 Crystals",
|
||||
"price": "5.99",
|
||||
"charge_crystal_num": "350",
|
||||
"free_crystal_num": "0",
|
||||
"purchase_limit": "999999999",
|
||||
"special_shop_flag": "0",
|
||||
"image_name": "thumbnail_crystal",
|
||||
"start_time": "2022-10-05 15:00:00",
|
||||
"end_time": "2030-03-01 14:59:59",
|
||||
"remaining_time": "0",
|
||||
"is_resale_product": "0",
|
||||
"resale_start_date": "",
|
||||
"purchase_num_current": 0
|
||||
},
|
||||
"80000": {
|
||||
"record_id": "30",
|
||||
"id": "800",
|
||||
"store_product_id": "80000",
|
||||
"name": "1200-crystal and Legendary set",
|
||||
"text": "Purchase 1200 Crystals and Legendary set",
|
||||
"price": "20.99",
|
||||
"charge_crystal_num": "1200",
|
||||
"free_crystal_num": "0",
|
||||
"purchase_limit": "3",
|
||||
"special_shop_flag": "1",
|
||||
"image_name": "thumbnail_crystal_strong",
|
||||
"start_time": "2018-01-01 00:00:00",
|
||||
"end_time": "2019-03-19 16:15:17",
|
||||
"remaining_time": "604800",
|
||||
"is_resale_product": "0",
|
||||
"resale_start_date": "",
|
||||
"purchase_num_current": 0
|
||||
},
|
||||
"98900": {
|
||||
"record_id": "19",
|
||||
"id": "989",
|
||||
"store_product_id": "98900",
|
||||
"name": "[b]1-Time Deal![/b] 1000-crystal set",
|
||||
"text": "Purchase 1000 Crystals",
|
||||
"price": "15.99",
|
||||
"charge_crystal_num": "1000",
|
||||
"free_crystal_num": "0",
|
||||
"purchase_limit": "1",
|
||||
"special_shop_flag": "0",
|
||||
"image_name": "thumbnail_crystal_strong",
|
||||
"start_time": "2026-04-01 02:00:00",
|
||||
"end_time": "2026-07-01 01:59:59",
|
||||
"remaining_time": "0",
|
||||
"is_resale_product": "1",
|
||||
"resale_start_date": "2026-04-01 02:00:00",
|
||||
"purchase_num_current": 0
|
||||
},
|
||||
"99200": {
|
||||
"record_id": "3",
|
||||
"id": "992",
|
||||
"store_product_id": "99200",
|
||||
"name": "[b]One-time Deal![/b] 800-crystal set",
|
||||
"text": "Purchase 800 Crystals",
|
||||
"price": "7.99",
|
||||
"charge_crystal_num": "800",
|
||||
"free_crystal_num": "0",
|
||||
"purchase_limit": "1",
|
||||
"special_shop_flag": "0",
|
||||
"image_name": "thumbnail_crystal_strong",
|
||||
"start_time": "2018-01-30 04:00:00",
|
||||
"end_time": "2030-03-01 14:59:59",
|
||||
"remaining_time": "0",
|
||||
"is_resale_product": "0",
|
||||
"resale_start_date": "",
|
||||
"purchase_num_current": 0
|
||||
},
|
||||
"99400": {
|
||||
"record_id": "10",
|
||||
"id": "994",
|
||||
"store_product_id": "99400",
|
||||
"name": "[b]Special Offer![/b] 7500-crystal set (3 times per person)",
|
||||
"text": "Purchase 7500 Crystals",
|
||||
"price": "79.99",
|
||||
"charge_crystal_num": "7500",
|
||||
"free_crystal_num": "0",
|
||||
"purchase_limit": "3",
|
||||
"special_shop_flag": "0",
|
||||
"image_name": "thumbnail_crystal_strong",
|
||||
"start_time": "2017-06-01 06:00:00",
|
||||
"end_time": "2030-03-01 14:59:59",
|
||||
"remaining_time": "0",
|
||||
"is_resale_product": "0",
|
||||
"resale_start_date": "",
|
||||
"purchase_num_current": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
182
SVSim.Bootstrap/Data/seeds/payment-items.json
Normal file
182
SVSim.Bootstrap/Data/seeds/payment-items.json
Normal file
@@ -0,0 +1,182 @@
|
||||
[
|
||||
{
|
||||
"record_id": 3,
|
||||
"product_id": 992,
|
||||
"store_product_id": 99200,
|
||||
"name": "[b]One-time Deal![/b] 800-crystal set",
|
||||
"text": "Purchase 800 Crystals",
|
||||
"price": "7.99",
|
||||
"charge_crystal_num": 800,
|
||||
"free_crystal_num": 0,
|
||||
"purchase_limit": 1,
|
||||
"special_shop_flag": 0,
|
||||
"image_name": "thumbnail_crystal_strong",
|
||||
"start_time": "2018-01-30 04:00:00",
|
||||
"end_time": "2030-03-01 14:59:59",
|
||||
"remaining_time": 0,
|
||||
"is_resale_product": 0,
|
||||
"resale_start_date": ""
|
||||
},
|
||||
{
|
||||
"record_id": 10,
|
||||
"product_id": 994,
|
||||
"store_product_id": 99400,
|
||||
"name": "[b]Special Offer![/b] 7500-crystal set (3 times per person)",
|
||||
"text": "Purchase 7500 Crystals",
|
||||
"price": "79.99",
|
||||
"charge_crystal_num": 7500,
|
||||
"free_crystal_num": 0,
|
||||
"purchase_limit": 3,
|
||||
"special_shop_flag": 0,
|
||||
"image_name": "thumbnail_crystal_strong",
|
||||
"start_time": "2017-06-01 06:00:00",
|
||||
"end_time": "2030-03-01 14:59:59",
|
||||
"remaining_time": 0,
|
||||
"is_resale_product": 0,
|
||||
"resale_start_date": ""
|
||||
},
|
||||
{
|
||||
"record_id": 19,
|
||||
"product_id": 989,
|
||||
"store_product_id": 98900,
|
||||
"name": "[b]1-Time Deal![/b] 1000-crystal set",
|
||||
"text": "Purchase 1000 Crystals",
|
||||
"price": "15.99",
|
||||
"charge_crystal_num": 1000,
|
||||
"free_crystal_num": 0,
|
||||
"purchase_limit": 1,
|
||||
"special_shop_flag": 0,
|
||||
"image_name": "thumbnail_crystal_strong",
|
||||
"start_time": "2026-04-01 02:00:00",
|
||||
"end_time": "2026-07-01 01:59:59",
|
||||
"remaining_time": 0,
|
||||
"is_resale_product": 1,
|
||||
"resale_start_date": "2026-04-01 02:00:00"
|
||||
},
|
||||
{
|
||||
"record_id": 21,
|
||||
"product_id": 8,
|
||||
"store_product_id": 10011,
|
||||
"name": "60-crystal set",
|
||||
"text": "Purchase 60 Crystals",
|
||||
"price": "0.99",
|
||||
"charge_crystal_num": 60,
|
||||
"free_crystal_num": 0,
|
||||
"purchase_limit": 999999999,
|
||||
"special_shop_flag": 0,
|
||||
"image_name": "thumbnail_crystal",
|
||||
"start_time": "2022-10-05 15:00:00",
|
||||
"end_time": "2030-03-01 14:59:59",
|
||||
"remaining_time": 0,
|
||||
"is_resale_product": 0,
|
||||
"resale_start_date": ""
|
||||
},
|
||||
{
|
||||
"record_id": 24,
|
||||
"product_id": 9,
|
||||
"store_product_id": 70011,
|
||||
"name": "350-crystal set",
|
||||
"text": "Purchase 350 Crystals",
|
||||
"price": "5.99",
|
||||
"charge_crystal_num": 350,
|
||||
"free_crystal_num": 0,
|
||||
"purchase_limit": 999999999,
|
||||
"special_shop_flag": 0,
|
||||
"image_name": "thumbnail_crystal",
|
||||
"start_time": "2022-10-05 15:00:00",
|
||||
"end_time": "2030-03-01 14:59:59",
|
||||
"remaining_time": 0,
|
||||
"is_resale_product": 0,
|
||||
"resale_start_date": ""
|
||||
},
|
||||
{
|
||||
"record_id": 26,
|
||||
"product_id": 10,
|
||||
"store_product_id": 30011,
|
||||
"name": "670-crystal set",
|
||||
"text": "Purchase 670 Crystals",
|
||||
"price": "10.99",
|
||||
"charge_crystal_num": 670,
|
||||
"free_crystal_num": 0,
|
||||
"purchase_limit": 999999999,
|
||||
"special_shop_flag": 0,
|
||||
"image_name": "thumbnail_crystal",
|
||||
"start_time": "2022-10-05 15:00:00",
|
||||
"end_time": "2030-03-01 14:59:59",
|
||||
"remaining_time": 0,
|
||||
"is_resale_product": 0,
|
||||
"resale_start_date": ""
|
||||
},
|
||||
{
|
||||
"record_id": 27,
|
||||
"product_id": 4,
|
||||
"store_product_id": 40000,
|
||||
"name": "1200-crystal set",
|
||||
"text": "Purchase 1200 Crystals",
|
||||
"price": "20.99",
|
||||
"charge_crystal_num": 1200,
|
||||
"free_crystal_num": 0,
|
||||
"purchase_limit": 999999999,
|
||||
"special_shop_flag": 0,
|
||||
"image_name": "thumbnail_crystal",
|
||||
"start_time": "2015-03-01 15:00:00",
|
||||
"end_time": "2030-03-01 14:59:59",
|
||||
"remaining_time": 0,
|
||||
"is_resale_product": 0,
|
||||
"resale_start_date": ""
|
||||
},
|
||||
{
|
||||
"record_id": 28,
|
||||
"product_id": 5,
|
||||
"store_product_id": 50000,
|
||||
"name": "2400-crystal set",
|
||||
"text": "Purchase 2400 Crystals",
|
||||
"price": "39.99",
|
||||
"charge_crystal_num": 2400,
|
||||
"free_crystal_num": 0,
|
||||
"purchase_limit": 999999999,
|
||||
"special_shop_flag": 0,
|
||||
"image_name": "thumbnail_crystal",
|
||||
"start_time": "2015-03-01 15:00:00",
|
||||
"end_time": "2030-03-01 14:59:59",
|
||||
"remaining_time": 0,
|
||||
"is_resale_product": 0,
|
||||
"resale_start_date": ""
|
||||
},
|
||||
{
|
||||
"record_id": 29,
|
||||
"product_id": 6,
|
||||
"store_product_id": 60000,
|
||||
"name": "5000-crystal set",
|
||||
"text": "Purchase 5000 Crystals",
|
||||
"price": "79.99",
|
||||
"charge_crystal_num": 5000,
|
||||
"free_crystal_num": 0,
|
||||
"purchase_limit": 999999999,
|
||||
"special_shop_flag": 0,
|
||||
"image_name": "thumbnail_crystal",
|
||||
"start_time": "2015-03-01 15:00:00",
|
||||
"end_time": "2030-03-01 14:59:59",
|
||||
"remaining_time": 0,
|
||||
"is_resale_product": 0,
|
||||
"resale_start_date": ""
|
||||
},
|
||||
{
|
||||
"record_id": 30,
|
||||
"product_id": 800,
|
||||
"store_product_id": 80000,
|
||||
"name": "1200-crystal and Legendary set",
|
||||
"text": "Purchase 1200 Crystals and Legendary set",
|
||||
"price": "20.99",
|
||||
"charge_crystal_num": 1200,
|
||||
"free_crystal_num": 0,
|
||||
"purchase_limit": 3,
|
||||
"special_shop_flag": 1,
|
||||
"image_name": "thumbnail_crystal_strong",
|
||||
"start_time": "2018-01-01 00:00:00",
|
||||
"end_time": "2019-03-19 16:15:17",
|
||||
"remaining_time": 604800,
|
||||
"is_resale_product": 0,
|
||||
"resale_start_date": ""
|
||||
}
|
||||
]
|
||||
@@ -30,7 +30,6 @@ public class GlobalsImporter
|
||||
JsonElement? loadIndex = LoadCapture(capturesDir, "load-index");
|
||||
JsonElement? mypageIndex = LoadCapture(capturesDir, "mypage-index");
|
||||
JsonElement? deckInfo = LoadCapture(capturesDir, "deck-info");
|
||||
JsonElement? paymentItemList = LoadCapture(capturesDir, "payment-item-list");
|
||||
JsonElement? packInfo = LoadCapture(capturesDir, "pack-info");
|
||||
JsonElement? basicPuzzleInfo = LoadCapture(capturesDir, "basic-puzzle-info");
|
||||
JsonElement? basicPuzzleMission = LoadCapture(capturesDir, "basic-puzzle-mission");
|
||||
@@ -69,11 +68,6 @@ public class GlobalsImporter
|
||||
total += await ImportDefaultDecks(context, deckInfo.Value);
|
||||
}
|
||||
|
||||
if (paymentItemList.HasValue)
|
||||
{
|
||||
total += await ImportPaymentItems(context, paymentItemList.Value);
|
||||
}
|
||||
|
||||
if (packInfo.HasValue)
|
||||
{
|
||||
total += await ImportPacks(context, packInfo.Value);
|
||||
@@ -709,54 +703,6 @@ public class GlobalsImporter
|
||||
return created + updated;
|
||||
}
|
||||
|
||||
// ---------- Payment: Item list (Steam/PC storefront, dict-keyed by store_product_id) ----------
|
||||
|
||||
private async Task<int> ImportPaymentItems(SVSimDbContext context, JsonElement payment)
|
||||
{
|
||||
// The payment-item-list capture's `data` IS the product dict (no nested key like banner/colosseum).
|
||||
// LoadCapture already unwrapped `data` for us, so iterate the dict directly.
|
||||
if (payment.ValueKind != JsonValueKind.Object) return 0;
|
||||
|
||||
var existing = await context.PaymentItems.ToDictionaryAsync(e => e.Id);
|
||||
int created = 0, updated = 0;
|
||||
foreach (var kv in payment.EnumerateObject())
|
||||
{
|
||||
var v = kv.Value;
|
||||
if (v.ValueKind != JsonValueKind.Object) continue;
|
||||
|
||||
int recordId = GetInt(v, "record_id");
|
||||
if (recordId == 0) continue;
|
||||
|
||||
var entry = existing.TryGetValue(recordId, out var ex) ? ex : new PaymentItemEntry { Id = recordId };
|
||||
entry.ProductId = GetInt(v, "id");
|
||||
entry.StoreProductId = GetLong(v, "store_product_id");
|
||||
entry.Name = GetString(v, "name");
|
||||
entry.Text = GetString(v, "text");
|
||||
entry.Price = ParseDecimal(GetString(v, "price"));
|
||||
entry.ChargeCrystalNum = GetInt(v, "charge_crystal_num");
|
||||
entry.FreeCrystalNum = GetInt(v, "free_crystal_num");
|
||||
entry.PurchaseLimit = GetInt(v, "purchase_limit");
|
||||
entry.SpecialShopFlag = GetInt(v, "special_shop_flag");
|
||||
entry.ImageName = GetString(v, "image_name");
|
||||
entry.StartTime = ParseWireDateTime(GetString(v, "start_time"));
|
||||
entry.EndTime = ParseWireDateTime(GetString(v, "end_time"));
|
||||
entry.RemainingTime = GetInt(v, "remaining_time");
|
||||
entry.IsResaleProduct = GetInt(v, "is_resale_product");
|
||||
// resale_start_date is "" when unset — store null rather than DateTime.MinValue so the
|
||||
// controller can decide whether to emit "" or a real date string.
|
||||
string resaleRaw = GetString(v, "resale_start_date");
|
||||
entry.ResaleStartDate = string.IsNullOrWhiteSpace(resaleRaw) ? null : ParseWireDateTime(resaleRaw);
|
||||
|
||||
if (ex is null) { context.PaymentItems.Add(entry); created++; }
|
||||
else updated++;
|
||||
}
|
||||
Console.WriteLine($"[GlobalsImporter] PaymentItems: +{created}/~{updated}");
|
||||
return created + updated;
|
||||
}
|
||||
|
||||
private static decimal ParseDecimal(string s) =>
|
||||
decimal.TryParse(s, System.Globalization.NumberStyles.Number, System.Globalization.CultureInfo.InvariantCulture, out var d) ? d : 0m;
|
||||
|
||||
// ---------- Pack catalog ----------
|
||||
|
||||
/// <summary>
|
||||
|
||||
69
SVSim.Bootstrap/Importers/PaymentItemImporter.cs
Normal file
69
SVSim.Bootstrap/Importers/PaymentItemImporter.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Bootstrap.Models.Seed;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Bootstrap.Importers;
|
||||
|
||||
/// <summary>
|
||||
/// Idempotent upsert of Steam payment items from <c>seeds/payment-items.json</c>.
|
||||
/// Rows missing from the seed are LEFT INTACT.
|
||||
/// </summary>
|
||||
public class PaymentItemImporter
|
||||
{
|
||||
public async Task<int> ImportAsync(SVSimDbContext context, string seedDir)
|
||||
{
|
||||
string path = Path.Combine(seedDir, "payment-items.json");
|
||||
var seed = SeedLoader.LoadList<PaymentItemSeed>(path);
|
||||
if (seed.Count == 0)
|
||||
{
|
||||
Console.WriteLine("[PaymentItemImporter] No seed rows; skipping.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var existing = await context.PaymentItems.ToDictionaryAsync(e => e.Id);
|
||||
int created = 0, updated = 0;
|
||||
|
||||
foreach (var s in seed)
|
||||
{
|
||||
if (s.RecordId == 0) continue;
|
||||
|
||||
var entry = existing.TryGetValue(s.RecordId, out var ex)
|
||||
? ex : new PaymentItemEntry { Id = s.RecordId };
|
||||
|
||||
entry.ProductId = s.ProductId;
|
||||
entry.StoreProductId = s.StoreProductId;
|
||||
entry.Name = s.Name;
|
||||
entry.Text = s.Text;
|
||||
entry.Price = decimal.TryParse(s.Price, NumberStyles.Number, CultureInfo.InvariantCulture, out var d) ? d : 0m;
|
||||
entry.ChargeCrystalNum = s.ChargeCrystalNum;
|
||||
entry.FreeCrystalNum = s.FreeCrystalNum;
|
||||
entry.PurchaseLimit = s.PurchaseLimit;
|
||||
entry.SpecialShopFlag = s.SpecialShopFlag;
|
||||
entry.ImageName = s.ImageName;
|
||||
entry.StartTime = ParseDate(s.StartTime);
|
||||
entry.EndTime = ParseDate(s.EndTime);
|
||||
entry.RemainingTime = s.RemainingTime;
|
||||
entry.IsResaleProduct = s.IsResaleProduct;
|
||||
entry.ResaleStartDate = string.IsNullOrWhiteSpace(s.ResaleStartDate) ? null : ParseDate(s.ResaleStartDate);
|
||||
|
||||
if (ex is null)
|
||||
{
|
||||
context.PaymentItems.Add(entry);
|
||||
existing[s.RecordId] = entry;
|
||||
created++;
|
||||
}
|
||||
else updated++;
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
Console.WriteLine($"[PaymentItemImporter] +{created}/~{updated}");
|
||||
return created + updated;
|
||||
}
|
||||
|
||||
private static DateTime ParseDate(string s) =>
|
||||
DateTime.TryParse(s, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
|
||||
out var dt) ? dt : DateTime.MinValue;
|
||||
}
|
||||
23
SVSim.Bootstrap/Models/Seed/PaymentItemSeed.cs
Normal file
23
SVSim.Bootstrap/Models/Seed/PaymentItemSeed.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.Bootstrap.Models.Seed;
|
||||
|
||||
public sealed class PaymentItemSeed
|
||||
{
|
||||
[JsonPropertyName("record_id")] public int RecordId { get; set; }
|
||||
[JsonPropertyName("product_id")] public int ProductId { get; set; }
|
||||
[JsonPropertyName("store_product_id")] public long StoreProductId { get; set; }
|
||||
[JsonPropertyName("name")] public string Name { get; set; } = "";
|
||||
[JsonPropertyName("text")] public string Text { get; set; } = "";
|
||||
[JsonPropertyName("price")] public string Price { get; set; } = "0";
|
||||
[JsonPropertyName("charge_crystal_num")] public int ChargeCrystalNum { get; set; }
|
||||
[JsonPropertyName("free_crystal_num")] public int FreeCrystalNum { get; set; }
|
||||
[JsonPropertyName("purchase_limit")] public int PurchaseLimit { get; set; }
|
||||
[JsonPropertyName("special_shop_flag")] public int SpecialShopFlag { get; set; }
|
||||
[JsonPropertyName("image_name")] public string ImageName { get; set; } = "";
|
||||
[JsonPropertyName("start_time")] public string StartTime { get; set; } = "";
|
||||
[JsonPropertyName("end_time")] public string EndTime { get; set; } = "";
|
||||
[JsonPropertyName("remaining_time")] public int RemainingTime { get; set; }
|
||||
[JsonPropertyName("is_resale_product")] public int IsResaleProduct { get; set; }
|
||||
[JsonPropertyName("resale_start_date")] public string ResaleStartDate { get; set; } = "";
|
||||
}
|
||||
@@ -77,6 +77,7 @@ public static class Program
|
||||
{
|
||||
await new GlobalsImporter().ImportAllAsync(context, opts.CapturesDir);
|
||||
await new PracticeOpponentImporter().ImportAsync(context, opts.SeedDir);
|
||||
await new PaymentItemImporter().ImportAsync(context, opts.SeedDir);
|
||||
|
||||
// BuildDeck pipeline: series CSV → catalog JSON → package CSV. Catalog must run after
|
||||
// series CSV (FK on products → series) and before package CSV (so the catalog-side
|
||||
|
||||
87
SVSim.UnitTests/Importers/PaymentItemImporterTests.cs
Normal file
87
SVSim.UnitTests/Importers/PaymentItemImporterTests.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SVSim.Bootstrap.Importers;
|
||||
using SVSim.Database;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Importers;
|
||||
|
||||
public class PaymentItemImporterTests
|
||||
{
|
||||
private static string SeedDir => Path.Combine(AppContext.BaseDirectory, "Data", "seeds");
|
||||
|
||||
[Test]
|
||||
public async Task Imports_items_from_seed_file()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
|
||||
await new PaymentItemImporter().ImportAsync(db, SeedDir);
|
||||
|
||||
var items = await db.PaymentItems.OrderBy(p => p.Id).ToListAsync();
|
||||
Assert.That(items.Count, Is.GreaterThan(0), "seed file must contain items");
|
||||
Assert.That(items.All(i => i.Price >= 0m), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Is_idempotent_on_rerun()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
|
||||
await new PaymentItemImporter().ImportAsync(db, SeedDir);
|
||||
int before = await db.PaymentItems.CountAsync();
|
||||
await new PaymentItemImporter().ImportAsync(db, SeedDir);
|
||||
int after = await db.PaymentItems.CountAsync();
|
||||
|
||||
Assert.That(after, Is.EqualTo(before));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Leaves_existing_rows_untouched_when_missing_from_seed()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
|
||||
const int legacyId = 99999;
|
||||
db.PaymentItems.Add(new SVSim.Database.Models.PaymentItemEntry
|
||||
{
|
||||
Id = legacyId,
|
||||
ProductId = 0,
|
||||
Name = "legacy",
|
||||
Price = 0m,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await new PaymentItemImporter().ImportAsync(db, SeedDir);
|
||||
|
||||
var legacy = await db.PaymentItems.FindAsync(legacyId);
|
||||
Assert.That(legacy, Is.Not.Null, "seed-missing row must be left intact");
|
||||
Assert.That(legacy!.Name, Is.EqualTo("legacy"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Skips_rows_with_zero_record_id()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
|
||||
string tmp = Path.Combine(Path.GetTempPath(), $"seed-{Guid.NewGuid()}");
|
||||
Directory.CreateDirectory(tmp);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(tmp, "payment-items.json"),
|
||||
"[{\"record_id\":0,\"product_id\":1,\"name\":\"junk\",\"price\":\"0\"}]");
|
||||
|
||||
await new PaymentItemImporter().ImportAsync(db, tmp);
|
||||
|
||||
int count = await db.PaymentItems.CountAsync();
|
||||
Assert.That(count, Is.EqualTo(0), "rows with record_id=0 must not be inserted");
|
||||
}
|
||||
finally { Directory.Delete(tmp, true); }
|
||||
}
|
||||
}
|
||||
@@ -193,6 +193,7 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
|
||||
// Wired here so SeedGlobalsAsync callers (e.g. PracticeControllerTests) still see
|
||||
// practice-opponent rows after the corresponding block was lifted out of GlobalsImporter.
|
||||
await new PracticeOpponentImporter().ImportAsync(ctx, seedDir);
|
||||
await new PaymentItemImporter().ImportAsync(ctx, seedDir);
|
||||
}
|
||||
|
||||
/// <summary>Convenience: bake the X-Test-Viewer-Id header into a fresh client.</summary>
|
||||
|
||||
Reference in New Issue
Block a user