Scrobbling Stability (#3863)
Co-authored-by: Amelia <77553571+Fesaa@users.noreply.github.com>
This commit is contained in:
parent
45e24aa311
commit
14a8f5c1e5
19 changed files with 1622 additions and 806 deletions
|
@ -1,11 +1,17 @@
|
|||
using System.Linq;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs.Scrobbling;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Scrobble;
|
||||
using API.Helpers.Builders;
|
||||
using API.Services;
|
||||
using API.Services.Plus;
|
||||
using API.SignalR;
|
||||
using Kavita.Common;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
@ -15,11 +21,33 @@ namespace API.Tests.Services;
|
|||
|
||||
public class ScrobblingServiceTests : AbstractDbTest
|
||||
{
|
||||
private const int ChapterPages = 100;
|
||||
|
||||
/// <summary>
|
||||
/// {
|
||||
/// "Issuer": "Issuer",
|
||||
/// "Issued At": "2025-06-15T21:01:57.615Z",
|
||||
/// "Expiration": "2200-06-15T21:01:57.615Z"
|
||||
/// }
|
||||
/// </summary>
|
||||
/// <remarks>Our UnitTests will fail in 2200 :(</remarks>
|
||||
private const string ValidJwtToken =
|
||||
"eyJhbGciOiJIUzI1NiJ9.eyJJc3N1ZXIiOiJJc3N1ZXIiLCJleHAiOjcyNzI0NTAxMTcsImlhdCI6MTc1MDAyMTMxN30.zADmcGq_BfxbcV8vy4xw5Cbzn4COkmVINxgqpuL17Ng";
|
||||
|
||||
private readonly ScrobblingService _service;
|
||||
private readonly ILicenseService _licenseService;
|
||||
private readonly ILocalizationService _localizationService;
|
||||
private readonly ILogger<ScrobblingService> _logger;
|
||||
private readonly IEmailService _emailService;
|
||||
private readonly IKavitaPlusApiService _kavitaPlusApiService;
|
||||
/// <summary>
|
||||
/// IReaderService, without the ScrobblingService injected
|
||||
/// </summary>
|
||||
private readonly IReaderService _readerService;
|
||||
/// <summary>
|
||||
/// IReaderService, with the _service injected
|
||||
/// </summary>
|
||||
private readonly IReaderService _hookedUpReaderService;
|
||||
|
||||
public ScrobblingServiceTests()
|
||||
{
|
||||
|
@ -27,8 +55,24 @@ public class ScrobblingServiceTests : AbstractDbTest
|
|||
_localizationService = Substitute.For<ILocalizationService>();
|
||||
_logger = Substitute.For<ILogger<ScrobblingService>>();
|
||||
_emailService = Substitute.For<IEmailService>();
|
||||
_kavitaPlusApiService = Substitute.For<IKavitaPlusApiService>();
|
||||
|
||||
_service = new ScrobblingService(UnitOfWork, Substitute.For<IEventHub>(), _logger, _licenseService, _localizationService, _emailService);
|
||||
_service = new ScrobblingService(UnitOfWork, Substitute.For<IEventHub>(), _logger, _licenseService,
|
||||
_localizationService, _emailService, _kavitaPlusApiService);
|
||||
|
||||
_readerService = new ReaderService(UnitOfWork,
|
||||
Substitute.For<ILogger<ReaderService>>(),
|
||||
Substitute.For<IEventHub>(),
|
||||
Substitute.For<IImageService>(),
|
||||
Substitute.For<IDirectoryService>(),
|
||||
Substitute.For<IScrobblingService>()); // Do not use the actual one
|
||||
|
||||
_hookedUpReaderService = new ReaderService(UnitOfWork,
|
||||
Substitute.For<ILogger<ReaderService>>(),
|
||||
Substitute.For<IEventHub>(),
|
||||
Substitute.For<IImageService>(),
|
||||
Substitute.For<IDirectoryService>(),
|
||||
_service);
|
||||
}
|
||||
|
||||
protected override async Task ResetDb()
|
||||
|
@ -46,6 +90,30 @@ public class ScrobblingServiceTests : AbstractDbTest
|
|||
var series = new SeriesBuilder("Test Series")
|
||||
.WithFormat(MangaFormat.Archive)
|
||||
.WithMetadata(new SeriesMetadataBuilder().Build())
|
||||
.WithVolume(new VolumeBuilder("Volume 1")
|
||||
.WithChapters([
|
||||
new ChapterBuilder("1")
|
||||
.WithPages(ChapterPages)
|
||||
.Build(),
|
||||
new ChapterBuilder("2")
|
||||
.WithPages(ChapterPages)
|
||||
.Build(),
|
||||
new ChapterBuilder("3")
|
||||
.WithPages(ChapterPages)
|
||||
.Build()])
|
||||
.Build())
|
||||
.WithVolume(new VolumeBuilder("Volume 2")
|
||||
.WithChapters([
|
||||
new ChapterBuilder("4")
|
||||
.WithPages(ChapterPages)
|
||||
.Build(),
|
||||
new ChapterBuilder("5")
|
||||
.WithPages(ChapterPages)
|
||||
.Build(),
|
||||
new ChapterBuilder("6")
|
||||
.WithPages(ChapterPages)
|
||||
.Build()])
|
||||
.Build())
|
||||
.Build();
|
||||
|
||||
var library = new LibraryBuilder("Test Library", LibraryType.Manga)
|
||||
|
@ -67,6 +135,296 @@ public class ScrobblingServiceTests : AbstractDbTest
|
|||
await UnitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
private async Task<ScrobbleEvent> CreateScrobbleEvent(int? seriesId = null)
|
||||
{
|
||||
var evt = new ScrobbleEvent
|
||||
{
|
||||
ScrobbleEventType = ScrobbleEventType.ChapterRead,
|
||||
Format = PlusMediaFormat.Manga,
|
||||
SeriesId = seriesId ?? 0,
|
||||
LibraryId = 0,
|
||||
AppUserId = 0,
|
||||
};
|
||||
|
||||
if (seriesId != null)
|
||||
{
|
||||
var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId.Value);
|
||||
if (series != null) evt.Series = series;
|
||||
}
|
||||
|
||||
return evt;
|
||||
}
|
||||
|
||||
|
||||
#region K+ API Request Tests
|
||||
|
||||
[Fact]
|
||||
public async Task PostScrobbleUpdate_AuthErrors()
|
||||
{
|
||||
_kavitaPlusApiService.PostScrobbleUpdate(null!, "")
|
||||
.ReturnsForAnyArgs(new ScrobbleResponseDto()
|
||||
{
|
||||
ErrorMessage = "Unauthorized"
|
||||
});
|
||||
|
||||
var evt = await CreateScrobbleEvent();
|
||||
await Assert.ThrowsAsync<KavitaException>(async () =>
|
||||
{
|
||||
await _service.PostScrobbleUpdate(new ScrobbleDto(), "", evt);
|
||||
});
|
||||
Assert.True(evt.IsErrored);
|
||||
Assert.Equal("Kavita+ subscription no longer active", evt.ErrorDetails);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostScrobbleUpdate_UnknownSeriesLoggedAsError()
|
||||
{
|
||||
_kavitaPlusApiService.PostScrobbleUpdate(null!, "")
|
||||
.ReturnsForAnyArgs(new ScrobbleResponseDto()
|
||||
{
|
||||
ErrorMessage = "Unknown Series"
|
||||
});
|
||||
|
||||
await SeedData();
|
||||
var evt = await CreateScrobbleEvent(1);
|
||||
|
||||
await _service.PostScrobbleUpdate(new ScrobbleDto(), "", evt);
|
||||
await UnitOfWork.CommitAsync();
|
||||
Assert.True(evt.IsErrored);
|
||||
|
||||
var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1);
|
||||
Assert.NotNull(series);
|
||||
Assert.True(series.IsBlacklisted);
|
||||
|
||||
var errors = await UnitOfWork.ScrobbleRepository.GetAllScrobbleErrorsForSeries(1);
|
||||
Assert.Single(errors);
|
||||
Assert.Equal("Series cannot be matched for Scrobbling", errors.First().Comment);
|
||||
Assert.Equal(series.Id, errors.First().SeriesId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostScrobbleUpdate_InvalidAccessToken()
|
||||
{
|
||||
_kavitaPlusApiService.PostScrobbleUpdate(null!, "")
|
||||
.ReturnsForAnyArgs(new ScrobbleResponseDto()
|
||||
{
|
||||
ErrorMessage = "Access token is invalid"
|
||||
});
|
||||
|
||||
var evt = await CreateScrobbleEvent();
|
||||
|
||||
await Assert.ThrowsAsync<KavitaException>(async () =>
|
||||
{
|
||||
await _service.PostScrobbleUpdate(new ScrobbleDto(), "", evt);
|
||||
});
|
||||
|
||||
Assert.True(evt.IsErrored);
|
||||
Assert.Equal("Access Token needs to be rotated to continue scrobbling", evt.ErrorDetails);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region K+ API Request data tests
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessReadEvents_CreatesNoEventsWhenNoProgress()
|
||||
{
|
||||
await ResetDb();
|
||||
await SeedData();
|
||||
|
||||
// Set Returns
|
||||
_licenseService.HasActiveLicense().Returns(Task.FromResult(true));
|
||||
_kavitaPlusApiService.GetRateLimit(Arg.Any<string>(), Arg.Any<string>())
|
||||
.Returns(100);
|
||||
|
||||
var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1);
|
||||
Assert.NotNull(user);
|
||||
|
||||
// Ensure CanProcessScrobbleEvent returns true
|
||||
user.AniListAccessToken = ValidJwtToken;
|
||||
UnitOfWork.UserRepository.Update(user);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
var chapter = await UnitOfWork.ChapterRepository.GetChapterAsync(4);
|
||||
Assert.NotNull(chapter);
|
||||
|
||||
var volume = await UnitOfWork.VolumeRepository.GetVolumeAsync(1, VolumeIncludes.Chapters);
|
||||
Assert.NotNull(volume);
|
||||
|
||||
// Call Scrobble without having any progress
|
||||
await _service.ScrobbleReadingUpdate(1, 1);
|
||||
var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
|
||||
Assert.Empty(events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessReadEvents_UpdateVolumeAndChapterData()
|
||||
{
|
||||
await ResetDb();
|
||||
await SeedData();
|
||||
|
||||
// Set Returns
|
||||
_licenseService.HasActiveLicense().Returns(Task.FromResult(true));
|
||||
_kavitaPlusApiService.GetRateLimit(Arg.Any<string>(), Arg.Any<string>())
|
||||
.Returns(100);
|
||||
|
||||
var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1);
|
||||
Assert.NotNull(user);
|
||||
|
||||
// Ensure CanProcessScrobbleEvent returns true
|
||||
user.AniListAccessToken = ValidJwtToken;
|
||||
UnitOfWork.UserRepository.Update(user);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
var chapter = await UnitOfWork.ChapterRepository.GetChapterAsync(4);
|
||||
Assert.NotNull(chapter);
|
||||
|
||||
var volume = await UnitOfWork.VolumeRepository.GetVolumeAsync(1, VolumeIncludes.Chapters);
|
||||
Assert.NotNull(volume);
|
||||
|
||||
// Mark something as read to trigger event creation
|
||||
await _readerService.MarkChaptersAsRead(user, 1, new List<Chapter>() {volume.Chapters[0]});
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
// Call Scrobble while having some progress
|
||||
await _service.ScrobbleReadingUpdate(user.Id, 1);
|
||||
var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
|
||||
Assert.Single(events);
|
||||
|
||||
// Give it some (more) read progress
|
||||
await _readerService.MarkChaptersAsRead(user, 1, volume.Chapters);
|
||||
await _readerService.MarkChaptersAsRead(user, 1, [chapter]);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
await _service.ProcessUpdatesSinceLastSync();
|
||||
|
||||
await _kavitaPlusApiService.Received(1).PostScrobbleUpdate(
|
||||
Arg.Is<ScrobbleDto>(data =>
|
||||
data.ChapterNumber == (int)chapter.MaxNumber &&
|
||||
data.VolumeNumber == (int)volume.MaxNumber
|
||||
),
|
||||
Arg.Any<string>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Scrobble Reading Update Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ScrobbleReadingUpdate_IgnoreNoLicense()
|
||||
{
|
||||
await ResetDb();
|
||||
await SeedData();
|
||||
|
||||
_licenseService.HasActiveLicense().Returns(false);
|
||||
|
||||
await _service.ScrobbleReadingUpdate(1, 1);
|
||||
var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
|
||||
Assert.Empty(events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScrobbleReadingUpdate_RemoveWhenNoProgress()
|
||||
{
|
||||
await ResetDb();
|
||||
await SeedData();
|
||||
|
||||
_licenseService.HasActiveLicense().Returns(true);
|
||||
|
||||
var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1);
|
||||
Assert.NotNull(user);
|
||||
|
||||
var volume = await UnitOfWork.VolumeRepository.GetVolumeAsync(1, VolumeIncludes.Chapters);
|
||||
Assert.NotNull(volume);
|
||||
|
||||
await _readerService.MarkChaptersAsRead(user, 1, new List<Chapter>() {volume.Chapters[0]});
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
await _service.ScrobbleReadingUpdate(1, 1);
|
||||
var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
|
||||
Assert.Single(events);
|
||||
|
||||
var readEvent = events.First();
|
||||
Assert.False(readEvent.IsProcessed);
|
||||
|
||||
await _hookedUpReaderService.MarkSeriesAsUnread(user, 1);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
// Existing event is deleted
|
||||
await _service.ScrobbleReadingUpdate(1, 1);
|
||||
events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
|
||||
Assert.Empty(events);
|
||||
|
||||
await _hookedUpReaderService.MarkSeriesAsUnread(user, 1);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
// No new events are added
|
||||
events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
|
||||
Assert.Empty(events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScrobbleReadingUpdate_UpdateExistingNotIsProcessed()
|
||||
{
|
||||
await ResetDb();
|
||||
await SeedData();
|
||||
|
||||
var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1);
|
||||
Assert.NotNull(user);
|
||||
|
||||
var chapter1 = await UnitOfWork.ChapterRepository.GetChapterAsync(1);
|
||||
var chapter2 = await UnitOfWork.ChapterRepository.GetChapterAsync(2);
|
||||
var chapter3 = await UnitOfWork.ChapterRepository.GetChapterAsync(3);
|
||||
Assert.NotNull(chapter1);
|
||||
Assert.NotNull(chapter2);
|
||||
Assert.NotNull(chapter3);
|
||||
|
||||
_licenseService.HasActiveLicense().Returns(true);
|
||||
|
||||
var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
|
||||
Assert.Empty(events);
|
||||
|
||||
|
||||
await _readerService.MarkChaptersAsRead(user, 1, [chapter1]);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
// Scrobble update
|
||||
await _service.ScrobbleReadingUpdate(1, 1);
|
||||
events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
|
||||
Assert.Single(events);
|
||||
|
||||
var readEvent = events[0];
|
||||
Assert.False(readEvent.IsProcessed);
|
||||
Assert.Equal(1, readEvent.ChapterNumber);
|
||||
|
||||
// Mark as processed
|
||||
readEvent.IsProcessed = true;
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
await _readerService.MarkChaptersAsRead(user, 1, [chapter2]);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
// Scrobble update
|
||||
await _service.ScrobbleReadingUpdate(1, 1);
|
||||
events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
|
||||
Assert.Equal(2, events.Count);
|
||||
Assert.Single(events.Where(e => e.IsProcessed).ToList());
|
||||
Assert.Single(events.Where(e => !e.IsProcessed).ToList());
|
||||
|
||||
// Should update the existing non processed event
|
||||
await _readerService.MarkChaptersAsRead(user, 1, [chapter3]);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
// Scrobble update
|
||||
await _service.ScrobbleReadingUpdate(1, 1);
|
||||
events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
|
||||
Assert.Equal(2, events.Count);
|
||||
Assert.Single(events.Where(e => e.IsProcessed).ToList());
|
||||
Assert.Single(events.Where(e => !e.IsProcessed).ToList());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ScrobbleWantToReadUpdate Tests
|
||||
|
||||
[Fact]
|
||||
|
@ -203,6 +561,59 @@ public class ScrobblingServiceTests : AbstractDbTest
|
|||
|
||||
#endregion
|
||||
|
||||
#region Scrobble Rating Update Test
|
||||
|
||||
[Fact]
|
||||
public async Task ScrobbleRatingUpdate_IgnoreNoLicense()
|
||||
{
|
||||
await ResetDb();
|
||||
await SeedData();
|
||||
|
||||
_licenseService.HasActiveLicense().Returns(false);
|
||||
|
||||
await _service.ScrobbleRatingUpdate(1, 1, 1);
|
||||
var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
|
||||
Assert.Empty(events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScrobbleRatingUpdate_UpdateExistingNotIsProcessed()
|
||||
{
|
||||
await ResetDb();
|
||||
await SeedData();
|
||||
|
||||
_licenseService.HasActiveLicense().Returns(true);
|
||||
|
||||
var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1);
|
||||
Assert.NotNull(user);
|
||||
|
||||
var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1);
|
||||
Assert.NotNull(series);
|
||||
|
||||
await _service.ScrobbleRatingUpdate(user.Id, series.Id, 1);
|
||||
var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
|
||||
Assert.Single(events);
|
||||
Assert.Equal(1, events.First().Rating);
|
||||
|
||||
// Mark as processed
|
||||
events.First().IsProcessed = true;
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
await _service.ScrobbleRatingUpdate(user.Id, series.Id, 5);
|
||||
events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
|
||||
Assert.Equal(2, events.Count);
|
||||
Assert.Single(events, evt => evt.IsProcessed);
|
||||
Assert.Single(events, evt => !evt.IsProcessed);
|
||||
|
||||
await _service.ScrobbleRatingUpdate(user.Id, series.Id, 5);
|
||||
events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
|
||||
Assert.Single(events, evt => !evt.IsProcessed);
|
||||
Assert.Equal(5, events.First(evt => !evt.IsProcessed).Rating);
|
||||
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://anilist.co/manga/35851/Byeontaega-Doeja/", 35851)]
|
||||
[InlineData("https://anilist.co/manga/30105", 30105)]
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue