using JSMR.Application.Integrations.DLSite.Models; using JSMR.Application.Integrations.Ports; using JSMR.Application.Scanning.Contracts; using JSMR.Application.Scanning.Ports; using JSMR.Infrastructure.Http; using JSMR.Infrastructure.Scanning; using JSMR.Tests.Extensions; using JSMR.Tests.Utilities; using NSubstitute; using Shouldly; namespace JSMR.Tests.Scanning; public class VoiceWorkScannerTests { private static async Task ReadResourceAsync(string resourceName) { return await ResourceHelper.ReadAsync($"JSMR.Tests.Scanning.{resourceName}"); } [Fact] public async Task Scan_With_Japanese_Locale() { string html = await ReadResourceAsync("Japanese-Page.html"); IHttpService httpService = Substitute.For(); httpService.ReturnsContent(html); HtmlLoader loader = new(httpService); JapaneseVoiceWorksScanner scanner = new(loader); VoiceWorkScanOptions options = new( PageNumber: 1, PageSize: 100, ExcludeAIGeneratedWorks: true, ExcludePartiallyAIGeneratedWorks: true, ExcludedMakerIds: [] ); var result = await scanner.ScanWorksAsync(options); result.Count.ShouldBe(1); result[0].ExpectedDate.ShouldBeNull(); result[0].SalesDate.ShouldBe(new DateOnly(2025, 10, 15)); result[0].ProductId.ShouldBe("RJ01462066"); result[0].ProductName.ShouldBe("小悪魔ドSメンズエステ嬢の暴発解禁キワキワ施術"); result[0].Description.ShouldBe("リピート指名していたオキニ嬢の在籍店が閉店し途方に暮れていた貴方だったが、ある日彼女の個人SNSで別のお店のリンクを発見する。大慌てで当日夜の予約を確保し、今度こそ抜いてくれないかと淡い期待を胸に駅近くのマンションの一室へ…。"); result[0].Maker.ShouldBe("シルトクレーテ"); result[0].MakerId.ShouldBe("RG40741"); result[0].Creators.ShouldBe(["柚木つばめ"]); result[0].Genres.ShouldBe(["体験版"]); result[0].Tags.ShouldBe(["バイノーラル/ダミヘ", "手コキ", "足コキ", "パイズリ", "言葉責め", "焦らし", "乳首責め", "本番なし"]); result[0].Downloads.ShouldBe(1220); } [Fact] public async Task Scan_With_Updated_Japanese_Locale() { string html = await ReadResourceAsync("Japanese-Page-Updated.html"); IHttpService httpService = Substitute.For(); httpService.ReturnsContent(html); HtmlLoader loader = new(httpService); JapaneseVoiceWorksScanner scanner = new(loader); VoiceWorkScanOptions options = new( PageNumber: 1, PageSize: 100, ExcludeAIGeneratedWorks: true, ExcludePartiallyAIGeneratedWorks: true, ExcludedMakerIds: [] ); var result = await scanner.ScanWorksAsync(options); result.Count.ShouldBe(2); result[0].SalesDate.ShouldBeNull(); result[0].ExpectedDate.ShouldBe(new DateOnly(2026, 12, 21)); result[0].ProductId.ShouldBe("RJ01536422"); result[0].ProductName.ShouldBe("珈琲屋 綴 / いつもいつでも〜Alone with you〜"); result[0].Description.ShouldBe("珈琲に特化した喫茶店、喫茶綴、外伝。『珈琲屋 綴』の従業員、綴明日菜が、大好きな珈琲と、貴方との時間を大切に育みます。珈琲に特化した喫茶店、喫茶綴の外伝です。CV:野上菜月様"); result[0].Maker.ShouldBe("喫茶綴"); result[0].MakerId.ShouldBe("RG36156"); result[0].Creators.ShouldBe(["野上菜月"]); result[0].Genres.ShouldBe(["全年齢"]); result[0].Tags.ShouldBe(["ASMR", "癒し", "オールハッピー", "バイノーラル/ダミヘ", "日常/生活", "耳かき"]); // TODO: Wishlist count? result[1].SalesDate.ShouldBeNull(); result[1].ExpectedDate.ShouldBe(new DateOnly(2026, 12, 21)); result[1].ProductId.ShouldBe("RJ01393816"); result[1].ProductName.ShouldBe("アダルトグッズショップの店長にオナ禁でオモチャにされる話"); result[1].Description.ShouldBe("アダルトグッズショップでダウナーなセンパイと仕事中にオナ禁サポートをしてサボっていたことが店長にバレてしまった。今度はセンパイの詩乃と店長のミチル、2人にオナ禁でオモチャにされることになってしまった。"); result[1].Maker.ShouldBe("平たい胸族"); result[1].MakerId.ShouldBe("RG01044380"); result[1].Creators.ShouldBe([]); result[1].Genres.ShouldBe([]); result[1].Tags.ShouldBe(["ASMR", "バイノーラル/ダミヘ", "色仕掛け", "浮気", "百合", "レズ/女同士", "ツルペタ", "貧乳/微乳"]); } // (Scan) + (Integration) = ==> Ingestion [Fact] public async Task Scan_And_Integration_Ingest_Mapping_Test() { IVoiceWorksScanner scanner = Substitute.For(); DLSiteWork[] scannedWorks = [ new() { ProductId = "RJ1", ProductName = "Masked Product Title", MakerId = "RG1", Maker = "Some Maker", ImageUrl = "https://site.com/image_main.png", SmallImageUrl = "https://site.com/image_240x240.png" } ]; VoiceWorkScanResult scanResult = new( Works: scannedWorks, EndOfResults: false ); scanner.ScanPageAsync(Arg.Any(), CancellationToken.None) .Returns(Task.FromResult(scanResult)); IDLSiteClient dlsiteClient = Substitute.For(); VoiceWorkDetailCollection detailCollection = new() { { "RJ1", new VoiceWorkDetails() { Title = "Product Title" } } }; dlsiteClient.GetVoiceWorkDetailsAsync(Arg.Any(), CancellationToken.None) .Returns(Task.FromResult(detailCollection)); VoiceWorkIngest ingest = VoiceWorkIngest.From(scannedWorks[0], detailCollection["RJ1"]); // TODO: Test other fields ingest.Title.ShouldBe("Product Title"); ingest.HasImage.ShouldBe(true); } [Fact] public async Task Scan_With_English_Locale() { string englishPageHtml = await ReadResourceAsync("English-Page.html"); IHttpService httpService = Substitute.For(); httpService.ReturnsContent(englishPageHtml); HtmlLoader loader = new(httpService); EnglishVoiceWorksScanner scanner = new(loader); VoiceWorkScanOptions options = new( PageNumber: 1, PageSize: 100, ExcludeAIGeneratedWorks: true, ExcludePartiallyAIGeneratedWorks: true, ExcludedMakerIds: [] ); var result = await scanner.ScanWorksAsync(options); result.Count.ShouldBe(2); result[0].ExpectedDate.ShouldBeNull(); result[0].SalesDate.ShouldBe(new DateOnly(2025, 9, 6)); result[0].ProductId.ShouldBe("RJ00000001"); result[0].ProductName.ShouldBe("Title of Product"); result[0].Description.ShouldBe("Description of the product."); result[0].Maker.ShouldBe("The Maker"); result[0].MakerId.ShouldBe("RG00001"); result[0].Creators.ShouldBe(["Some Creator"]); result[0].Genres.ShouldBe(["Voice", "Trial version"]); result[0].Tags.ShouldBe(["Male Protagonist", "Gal", "Uniform", "Harem", "Big Breasts", "Tanned Skin / Suntan"]); result[0].Downloads.ShouldBe(1000); result[1].ExpectedDate.ShouldBe(new DateOnly(2025, 10, 11)); result[1].SalesDate.ShouldBeNull(); result[1].ProductId.ShouldBe("RJ00000002"); } [Fact] public async Task Scan_With_Updated_English_Locale() { string html = await ReadResourceAsync("English-Page-Updated.html"); IHttpService httpService = Substitute.For(); httpService.ReturnsContent(html); HtmlLoader loader = new(httpService); EnglishVoiceWorksScanner scanner = new(loader); VoiceWorkScanOptions options = new( PageNumber: 1, PageSize: 100, ExcludeAIGeneratedWorks: true, ExcludePartiallyAIGeneratedWorks: true, ExcludedMakerIds: [] ); var result = await scanner.ScanWorksAsync(options); result.Count.ShouldBe(1); result[0].ExpectedDate.ShouldBeNull(); result[0].SalesDate.ShouldBe(new DateOnly(2025, 10, 16)); result[0].ProductId.ShouldBe("RJ01455722"); result[0].ProductName.ShouldBe("[ENG Sub] Welcome to Soleil!"); result[0].Description.ShouldBe(string.Empty); // Waiting on this to get fixed on the site result[0].Maker.ShouldBe("Translators Unite"); result[0].MakerId.ShouldBe("RG60289"); result[0].Creators.ShouldBe(["沼倉愛美"]); result[0].Genres.ShouldBe(["All Ages", "Trial version"]); result[0].Tags.ShouldBe(["Moe", "Healing", "Binaural", "ASMR", "Ear Cleaning", "Slice of Life / Daily Living", "Heartwarming", "Whispering"]); result[0].Downloads.ShouldBe(1); } }