diff --git a/JSMR.Infrastructure/Ingestion/VoiceWorkUpdater.cs b/JSMR.Infrastructure/Ingestion/VoiceWorkUpdater.cs index 5e5511a..c00dd3f 100644 --- a/JSMR.Infrastructure/Ingestion/VoiceWorkUpdater.cs +++ b/JSMR.Infrastructure/Ingestion/VoiceWorkUpdater.cs @@ -26,7 +26,7 @@ public class VoiceWorkUpdater(AppDbContext dbContext, ITimeProvider timeProvider continue; } - Upsert(ingest, upsertContext); + result.Status = Upsert(ingest, upsertContext); } await dbContext.SaveChangesAsync(cancellationToken); @@ -119,20 +119,29 @@ public class VoiceWorkUpdater(AppDbContext dbContext, ITimeProvider timeProvider if (voiceWork.SalesDate is not null && ingest.SalesDate is null) { - string message = $"Voice work has a sales date of {voiceWork.SalesDate.Value.ToShortDateString()}, but parsed ingest does not"; + string message = $"Voice work has a sales date of {voiceWork.SalesDate.Value.ToShortDateString()}, but ingest does not"; result.Issues.Add(new(message, VoiceWorkUpsertIssueSeverity.Error)); } } - private void Upsert(VoiceWorkIngest ingest, VoiceWorkUpsertContext upsertContext) + private VoiceWorkUpsertStatus Upsert(VoiceWorkIngest ingest, VoiceWorkUpsertContext upsertContext) { UpsertCircle(ingest, upsertContext); - UpsertVoiceWork(ingest, upsertContext); + + VoiceWork voiceWork = UpsertVoiceWork(ingest, upsertContext); + UpsertTags(ingest, upsertContext); UpsertVoiceWorkTags(ingest, upsertContext); UpsertCreators(ingest, upsertContext); UpsertVoiceWorkCreators(ingest, upsertContext); UpsertVoiceWorkSupportedLanguages(ingest, upsertContext); + + return dbContext.Entry(voiceWork).State switch + { + EntityState.Added => VoiceWorkUpsertStatus.Inserted, + EntityState.Modified => VoiceWorkUpsertStatus.Updated, + _ => VoiceWorkUpsertStatus.Unchanged, + }; } private void UpsertCircle(VoiceWorkIngest ingest, VoiceWorkUpsertContext upsertContext) @@ -158,19 +167,11 @@ public class VoiceWorkUpdater(AppDbContext dbContext, ITimeProvider timeProvider return circle; } - private void UpsertVoiceWork(VoiceWorkIngest ingest, VoiceWorkUpsertContext upsertContext) + private VoiceWork UpsertVoiceWork(VoiceWorkIngest ingest, VoiceWorkUpsertContext upsertContext) { VoiceWork voiceWork = GetOrAddVoiceWork(ingest, upsertContext); VoiceWorkUpsertState state = ComputeVoiceWorkUpsertState(voiceWork, ingest, upsertContext); - //bool isAdded = dbContext.Entry(voiceWork).State == EntityState.Added; - //bool isWithinCurrentScanAnchor = voiceWork.LastScannedDate == upsertContext.CurrentScanAnchor.DateTime; - //bool isWithinPreviousScanAnchor = voiceWork.LastScannedDate == upsertContext.PreviousScanAnchor.DateTime; - //bool hasGoneOnSale = voiceWork.SalesDate is null && ingest.SalesDate is not null; - - //bool isNewUpcoming = ingest.SalesDate is null && (isAdded || isWithinCurrentScanAnchor || (isWithinPreviousScanAnchor && upsertContext.PreviousScanAnchor.DateTime.Hour == 16)); - //bool isNewOnSale = ingest.SalesDate is not null && (isAdded || hasGoneOnSale || isWithinCurrentScanAnchor); - voiceWork.Circle = upsertContext.Circles[ingest.MakerId]; voiceWork.ProductName = ingest.Title; voiceWork.Description = ingest.Description; @@ -199,6 +200,8 @@ public class VoiceWorkUpdater(AppDbContext dbContext, ITimeProvider timeProvider voiceWork.PlannedReleaseDate = ingest.RegistrationDate > upsertContext.CurrentScanAnchor ? ingest.RegistrationDate : null; voiceWork.Status = state.IsNewUpcoming ? (byte)VoiceWorkStatus.NewAndUpcoming : (byte)VoiceWorkStatus.Upcoming; } + + return voiceWork; } private VoiceWork GetOrAddVoiceWork(VoiceWorkIngest ingest, VoiceWorkUpsertContext upsertContext) @@ -218,11 +221,7 @@ public class VoiceWorkUpdater(AppDbContext dbContext, ITimeProvider timeProvider } private sealed record VoiceWorkUpsertState( - bool IsAdded, - bool ScannedThisAnchor, - bool ScannedPrevAt4pm, bool WentOnSale, - bool HasSalesDate, bool IsNewUpcoming, bool IsNewOnSale ); @@ -243,11 +242,7 @@ public class VoiceWorkUpdater(AppDbContext dbContext, ITimeProvider timeProvider bool isNewOnSale = hasSales && (isAdded || wentOnSale || scannedThis); return new VoiceWorkUpsertState( - IsAdded: isAdded, - ScannedThisAnchor: scannedThis, - ScannedPrevAt4pm: scannedPrevAt4pm, WentOnSale: wentOnSale, - HasSalesDate: hasSales, IsNewUpcoming: isNewUpcoming, IsNewOnSale: isNewOnSale ); diff --git a/JSMR.Tests/Ingestion/Japanese/Fail_Attempted_Insert_With_Spam_Circle_Tests.cs b/JSMR.Tests/Ingestion/Japanese/Fail_Attempted_Insert_With_Spam_Circle_Tests.cs new file mode 100644 index 0000000..89cadb7 --- /dev/null +++ b/JSMR.Tests/Ingestion/Japanese/Fail_Attempted_Insert_With_Spam_Circle_Tests.cs @@ -0,0 +1,55 @@ +using JSMR.Application.Common; +using JSMR.Application.Scanning.Contracts; +using JSMR.Application.Scanning.Ports; +using JSMR.Domain.Entities; +using JSMR.Infrastructure.Common.SupportedLanguages; +using JSMR.Infrastructure.Data; +using JSMR.Tests.Fixtures; +using Microsoft.EntityFrameworkCore; +using Shouldly; + +namespace JSMR.Tests.Ingestion.Japanese; + +public class Fail_Attempted_Insert_With_Spam_Circle_Tests(MariaDbContainerFixture container) : IngestionTestsBase(container) +{ + [Fact] + public async Task Fail_Attempted_Insert_With_Spam_Circle() + { + await using AppDbContext dbContext = await GetAppDbContextAsync(); + + VoiceWorkIngest ingest = new() + { + MakerId = "RG00004", + MakerName = "Never Again", + ProductId = "RJ3000002", + Title = "Probably Something with AI", + Description = "Some description.", + Tags = [], + Creators = [], + WishlistCount = 100, + Downloads = 0, + HasTrial = false, + HasDLPlay = false, + AgeRating = AgeRating.R18, + HasImage = false, + SupportedLanguages = [new JapaneseLanguage()], + SalesDate = null, + ExpectedDate = new DateOnly(2025, 2, 1) + }; + + VoiceWorkUpsertResult[] results = await UpsertAsync(dbContext, TokyoLocalToUtc(2025, 01, 09, 16, 00, 00), [ingest]); + + VoiceWork? voiceWork = await dbContext.VoiceWorks.FirstOrDefaultAsync(v => v.ProductId == ingest.ProductId, TestContext.Current.CancellationToken); + voiceWork.ShouldBeNull(); + + results.Length.ShouldBe(1); + results.Count(r => r.Status == VoiceWorkUpsertStatus.Inserted).ShouldBe(0); + results.Count(r => r.Status == VoiceWorkUpsertStatus.Updated).ShouldBe(0); + results.Count(r => r.Status == VoiceWorkUpsertStatus.Skipped).ShouldBe(1); + results.Sum(r => r.Issues.Count).ShouldBe(1); + + VoiceWorkUpsertIssue issue = results[0].Issues.ElementAt(0); + issue.Severity.ShouldBe(VoiceWorkUpsertIssueSeverity.Error); + issue.Message.ShouldBe($"Circle {ingest.MakerName} ({ingest.MakerId}) is a spam circle"); + } +} \ No newline at end of file diff --git a/JSMR.Tests/Ingestion/Japanese/Fail_Attempted_Update_With_Decreased_Downloads_Tests.cs b/JSMR.Tests/Ingestion/Japanese/Fail_Attempted_Update_With_Decreased_Downloads_Tests.cs new file mode 100644 index 0000000..c63cdac --- /dev/null +++ b/JSMR.Tests/Ingestion/Japanese/Fail_Attempted_Update_With_Decreased_Downloads_Tests.cs @@ -0,0 +1,75 @@ +using JSMR.Application.Common; +using JSMR.Application.Scanning.Contracts; +using JSMR.Application.Scanning.Ports; +using JSMR.Domain.Entities; +using JSMR.Infrastructure.Common.SupportedLanguages; +using JSMR.Infrastructure.Data; +using JSMR.Tests.Fixtures; +using Microsoft.EntityFrameworkCore; +using Shouldly; + +namespace JSMR.Tests.Ingestion.Japanese; + +public class Fail_Attempted_Update_With_Decreased_Downloads_Tests(MariaDbContainerFixture container) : IngestionTestsBase(container) +{ + [Fact] + public async Task Fail_Attempted_Update_With_Decreased_Downloads() + { + await using AppDbContext dbContext = await GetAppDbContextAsync(); + + VoiceWorkIngest ingest = new() + { + MakerId = "RG10001", + MakerName = "New Dreams", + ProductId = "RJ2000001", + Title = "Day One Release", + Description = "Releasing now.", + Tags = ["アイドル", "メガネ"], + Creators = ["かの仔"], + WishlistCount = 50, + Downloads = 10, + HasTrial = false, + HasDLPlay = false, + StarRating = null, + Votes = null, + AgeRating = AgeRating.AllAges, + HasImage = true, + SupportedLanguages = [new JapaneseLanguage()], + SalesDate = new DateOnly(2025, 1, 15), + ExpectedDate = null + }; + + VoiceWorkUpsertResult[] results = await UpsertAsync(dbContext, TokyoLocalToUtc(2025, 01, 15, 00, 00, 00), [ingest]); + + VoiceWork? voiceWork = await dbContext.VoiceWorks.FirstOrDefaultAsync(v => v.ProductId == ingest.ProductId, TestContext.Current.CancellationToken); + voiceWork.ShouldNotBeNull(); + voiceWork.Downloads.ShouldBe(10); + + results.Length.ShouldBe(1); + results.Count(r => r.Status == VoiceWorkUpsertStatus.Inserted).ShouldBe(1); + results.Count(r => r.Status == VoiceWorkUpsertStatus.Updated).ShouldBe(0); + results.Count(r => r.Status == VoiceWorkUpsertStatus.Skipped).ShouldBe(0); + results.Sum(r => r.Issues.Count).ShouldBe(0); + + VoiceWorkIngest updatedIngest = ingest with + { + Downloads = 9 + }; + + VoiceWorkUpsertResult[] updatedResults = await UpsertAsync(dbContext, TokyoLocalToUtc(2025, 01, 16, 00, 00, 00), [updatedIngest]); + + voiceWork = await dbContext.VoiceWorks.FirstOrDefaultAsync(v => v.ProductId == ingest.ProductId, TestContext.Current.CancellationToken); + voiceWork.ShouldNotBeNull(); + voiceWork.Downloads.ShouldBe(10); + + updatedResults.Length.ShouldBe(1); + updatedResults.Count(r => r.Status == VoiceWorkUpsertStatus.Inserted).ShouldBe(0); + updatedResults.Count(r => r.Status == VoiceWorkUpsertStatus.Updated).ShouldBe(0); + updatedResults.Count(r => r.Status == VoiceWorkUpsertStatus.Skipped).ShouldBe(1); + updatedResults.Sum(r => r.Issues.Count).ShouldBe(1); + + VoiceWorkUpsertIssue issue = updatedResults[0].Issues.ElementAt(0); + issue.Severity.ShouldBe(VoiceWorkUpsertIssueSeverity.Error); + issue.Message.ShouldBe($"Downloads have decreased from {voiceWork.Downloads} to {updatedIngest.Downloads}"); + } +} \ No newline at end of file diff --git a/JSMR.Tests/Ingestion/Japanese/Fail_Attempted_Update_With_Sales_Date_Reversal_Tests.cs b/JSMR.Tests/Ingestion/Japanese/Fail_Attempted_Update_With_Sales_Date_Reversal_Tests.cs new file mode 100644 index 0000000..cb76f32 --- /dev/null +++ b/JSMR.Tests/Ingestion/Japanese/Fail_Attempted_Update_With_Sales_Date_Reversal_Tests.cs @@ -0,0 +1,75 @@ +using JSMR.Application.Common; +using JSMR.Application.Scanning.Contracts; +using JSMR.Application.Scanning.Ports; +using JSMR.Domain.Entities; +using JSMR.Infrastructure.Common.SupportedLanguages; +using JSMR.Infrastructure.Data; +using JSMR.Tests.Fixtures; +using Microsoft.EntityFrameworkCore; +using Shouldly; + +namespace JSMR.Tests.Ingestion.Japanese; + +public class Fail_Attempted_Update_With_Sales_Date_Reversal_Tests(MariaDbContainerFixture container) : IngestionTestsBase(container) +{ + [Fact] + public async Task Fail_Attempted_Update_With_Decreased_Downloads() + { + await using AppDbContext dbContext = await GetAppDbContextAsync(); + + VoiceWorkIngest ingest = new() + { + MakerId = "RG10001", + MakerName = "New Dreams", + ProductId = "RJ2000001", + Title = "Day One Release", + Description = "Releasing now.", + Tags = ["アイドル", "メガネ"], + Creators = ["かの仔"], + WishlistCount = 50, + Downloads = 10, + HasTrial = false, + HasDLPlay = false, + StarRating = null, + Votes = null, + AgeRating = AgeRating.AllAges, + HasImage = true, + SupportedLanguages = [new JapaneseLanguage()], + SalesDate = new DateOnly(2025, 1, 15), + ExpectedDate = null + }; + + VoiceWorkUpsertResult[] results = await UpsertAsync(dbContext, TokyoLocalToUtc(2025, 01, 15, 00, 00, 00), [ingest]); + + VoiceWork? voiceWork = await dbContext.VoiceWorks.FirstOrDefaultAsync(v => v.ProductId == ingest.ProductId, TestContext.Current.CancellationToken); + voiceWork.ShouldNotBeNull(); + voiceWork.SalesDate.ShouldBe(new DateTime(2025, 1, 15)); + + results.Length.ShouldBe(1); + results.Count(r => r.Status == VoiceWorkUpsertStatus.Inserted).ShouldBe(1); + results.Count(r => r.Status == VoiceWorkUpsertStatus.Updated).ShouldBe(0); + results.Count(r => r.Status == VoiceWorkUpsertStatus.Skipped).ShouldBe(0); + results.Sum(r => r.Issues.Count).ShouldBe(0); + + VoiceWorkIngest updatedIngest = ingest with + { + SalesDate = null + }; + + VoiceWorkUpsertResult[] updatedResults = await UpsertAsync(dbContext, TokyoLocalToUtc(2025, 01, 16, 00, 00, 00), [updatedIngest]); + + voiceWork = await dbContext.VoiceWorks.FirstOrDefaultAsync(v => v.ProductId == ingest.ProductId, TestContext.Current.CancellationToken); + voiceWork.ShouldNotBeNull(); + voiceWork.SalesDate.ShouldBe(new DateTime(2025, 1, 15)); + + updatedResults.Length.ShouldBe(1); + updatedResults.Count(r => r.Status == VoiceWorkUpsertStatus.Inserted).ShouldBe(0); + updatedResults.Count(r => r.Status == VoiceWorkUpsertStatus.Updated).ShouldBe(0); + updatedResults.Count(r => r.Status == VoiceWorkUpsertStatus.Skipped).ShouldBe(1); + updatedResults.Sum(r => r.Issues.Count).ShouldBe(1); + + VoiceWorkUpsertIssue issue = updatedResults[0].Issues.ElementAt(0); + issue.Severity.ShouldBe(VoiceWorkUpsertIssueSeverity.Error); + issue.Message.ShouldBe($"Voice work has a sales date of {voiceWork.SalesDate!.Value.ToShortDateString()}, but ingest does not"); + } +} \ No newline at end of file diff --git a/JSMR.Tests/Ingestion/VoiceWorkUpsertFixture.cs b/JSMR.Tests/Ingestion/VoiceWorkUpsertFixture.cs index 9d8ab2c..6a7a86e 100644 --- a/JSMR.Tests/Ingestion/VoiceWorkUpsertFixture.cs +++ b/JSMR.Tests/Ingestion/VoiceWorkUpsertFixture.cs @@ -14,7 +14,8 @@ public static class VoiceWorkIngestionSeedData context.Circles.AddRange( new() { CircleId = 1, Name = "Good Dreams", MakerId = "RG00001" }, new() { CircleId = 2, Name = "Sweet Dreams", Favorite = true, MakerId = "RG00002" }, - new() { CircleId = 3, Name = "Nightmare Fuel", Blacklisted = true, MakerId = "RG00003" } + new() { CircleId = 3, Name = "Nightmare Fuel", Blacklisted = true, MakerId = "RG00003" }, + new() { CircleId = 4, Name = "Never Again", Spam = true, MakerId = "RG00004" } ); context.VoiceWorks.AddRange( @@ -63,27 +64,6 @@ public static class VoiceWorkIngestionSeedData new() { VoiceWorkId = 3, TagId = 5 }, // Tsundere new() { VoiceWorkId = 3, TagId = 9 } // Non-Fiction / Narrative - //new() { VoiceWorkId = 3, TagId = 1 }, - //new() { VoiceWorkId = 3, TagId = 1 }, - //new() { VoiceWorkId = 3, TagId = 1 }, - //new() { VoiceWorkId = 3, TagId = 1 }, - //new() { VoiceWorkId = 3, TagId = 1 }, - - //new() { VoiceWorkId = 4, TagId = 1 }, - //new() { VoiceWorkId = 4, TagId = 1 }, - //new() { VoiceWorkId = 4, TagId = 1 }, - //new() { VoiceWorkId = 4, TagId = 1 }, - //new() { VoiceWorkId = 4, TagId = 1 }, - //new() { VoiceWorkId = 4, TagId = 1 }, - //new() { VoiceWorkId = 4, TagId = 1 }, - - //new() { VoiceWorkId = 5, TagId = 5 } // Tsundere - //new() { VoiceWorkId = 5, TagId = 1 }, - //new() { VoiceWorkId = 5, TagId = 1 }, - //new() { VoiceWorkId = 5, TagId = 1 }, - //new() { VoiceWorkId = 5, TagId = 1 }, - //new() { VoiceWorkId = 5, TagId = 1 }, - //new() { VoiceWorkId = 5, TagId = 1 } ); context.Creators.AddRange(