From c23c56d46c53c64c6ea1abe470d24dfb0f8b1692 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 26 May 2026 13:59:50 -0400 Subject: [PATCH] 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 --- .../payment-item-list-2026-05-23.json | 201 ------------------ SVSim.Bootstrap/Data/seeds/payment-items.json | 182 ++++++++++++++++ SVSim.Bootstrap/Importers/GlobalsImporter.cs | 54 ----- .../Importers/PaymentItemImporter.cs | 69 ++++++ .../Models/Seed/PaymentItemSeed.cs | 23 ++ SVSim.Bootstrap/Program.cs | 1 + .../Importers/PaymentItemImporterTests.cs | 87 ++++++++ .../Infrastructure/SVSimTestFactory.cs | 1 + 8 files changed, 363 insertions(+), 255 deletions(-) delete mode 100644 SVSim.Bootstrap/Data/prod-captures/payment-item-list-2026-05-23.json create mode 100644 SVSim.Bootstrap/Data/seeds/payment-items.json create mode 100644 SVSim.Bootstrap/Importers/PaymentItemImporter.cs create mode 100644 SVSim.Bootstrap/Models/Seed/PaymentItemSeed.cs create mode 100644 SVSim.UnitTests/Importers/PaymentItemImporterTests.cs diff --git a/SVSim.Bootstrap/Data/prod-captures/payment-item-list-2026-05-23.json b/SVSim.Bootstrap/Data/prod-captures/payment-item-list-2026-05-23.json deleted file mode 100644 index 6d80869..0000000 --- a/SVSim.Bootstrap/Data/prod-captures/payment-item-list-2026-05-23.json +++ /dev/null @@ -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 - } - } -} diff --git a/SVSim.Bootstrap/Data/seeds/payment-items.json b/SVSim.Bootstrap/Data/seeds/payment-items.json new file mode 100644 index 0000000..710c5b4 --- /dev/null +++ b/SVSim.Bootstrap/Data/seeds/payment-items.json @@ -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": "" + } +] diff --git a/SVSim.Bootstrap/Importers/GlobalsImporter.cs b/SVSim.Bootstrap/Importers/GlobalsImporter.cs index 8a17b5a..29ede7e 100644 --- a/SVSim.Bootstrap/Importers/GlobalsImporter.cs +++ b/SVSim.Bootstrap/Importers/GlobalsImporter.cs @@ -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 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 ---------- /// diff --git a/SVSim.Bootstrap/Importers/PaymentItemImporter.cs b/SVSim.Bootstrap/Importers/PaymentItemImporter.cs new file mode 100644 index 0000000..06260d2 --- /dev/null +++ b/SVSim.Bootstrap/Importers/PaymentItemImporter.cs @@ -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; + +/// +/// Idempotent upsert of Steam payment items from seeds/payment-items.json. +/// Rows missing from the seed are LEFT INTACT. +/// +public class PaymentItemImporter +{ + public async Task ImportAsync(SVSimDbContext context, string seedDir) + { + string path = Path.Combine(seedDir, "payment-items.json"); + var seed = SeedLoader.LoadList(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; +} diff --git a/SVSim.Bootstrap/Models/Seed/PaymentItemSeed.cs b/SVSim.Bootstrap/Models/Seed/PaymentItemSeed.cs new file mode 100644 index 0000000..fd3f0cc --- /dev/null +++ b/SVSim.Bootstrap/Models/Seed/PaymentItemSeed.cs @@ -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; } = ""; +} diff --git a/SVSim.Bootstrap/Program.cs b/SVSim.Bootstrap/Program.cs index 6e2dd7a..3d27476 100644 --- a/SVSim.Bootstrap/Program.cs +++ b/SVSim.Bootstrap/Program.cs @@ -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 diff --git a/SVSim.UnitTests/Importers/PaymentItemImporterTests.cs b/SVSim.UnitTests/Importers/PaymentItemImporterTests.cs new file mode 100644 index 0000000..1828efe --- /dev/null +++ b/SVSim.UnitTests/Importers/PaymentItemImporterTests.cs @@ -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(); + + 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(); + + 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(); + + 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(); + + 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); } + } +} diff --git a/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs b/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs index 899883a..d1ab71e 100644 --- a/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs +++ b/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs @@ -193,6 +193,7 @@ internal sealed class SVSimTestFactory : WebApplicationFactory // 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); } /// Convenience: bake the X-Test-Viewer-Id header into a fresh client.