From 401fa3e611e2592e6db904109177256ce814433c Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Sun, 9 Mar 2025 11:21:43 -0500 Subject: [PATCH] v0.8.5.2 - Hotfix (#3608) --- .../Services/VersionUpdaterServiceTests.cs | 456 ++++++++++++++++++ API/DTOs/Update/UpdateNotificationDto.cs | 3 +- API/Services/Tasks/VersionUpdaterService.cs | 61 ++- Kavita.Common/Kavita.Common.csproj | 4 +- UI/Web/src/app/_models/wiki.ts | 2 +- .../manage-media-issues.component.ts | 21 +- 6 files changed, 524 insertions(+), 23 deletions(-) create mode 100644 API.Tests/Services/VersionUpdaterServiceTests.cs diff --git a/API.Tests/Services/VersionUpdaterServiceTests.cs b/API.Tests/Services/VersionUpdaterServiceTests.cs new file mode 100644 index 000000000..d3cdb4412 --- /dev/null +++ b/API.Tests/Services/VersionUpdaterServiceTests.cs @@ -0,0 +1,456 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Reflection; +using System.Threading.Tasks; +using API.DTOs.Update; +using API.Extensions; +using API.Services; +using API.Services.Tasks; +using API.SignalR; +using Flurl.Http; +using Flurl.Http.Testing; +using Kavita.Common.EnvironmentInfo; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace API.Tests.Services; + +public class VersionUpdaterServiceTests : IDisposable +{ + private readonly ILogger _logger; + private readonly IEventHub _eventHub; + private readonly IDirectoryService _directoryService; + private readonly VersionUpdaterService _service; + private readonly string _tempPath; + private readonly HttpTest _httpTest; + + public VersionUpdaterServiceTests() + { + _logger = Substitute.For>(); + _eventHub = Substitute.For(); + _directoryService = Substitute.For(); + + // Create temp directory for cache + _tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(_tempPath); + _directoryService.LongTermCacheDirectory.Returns(_tempPath); + + _service = new VersionUpdaterService(_logger, _eventHub, _directoryService); + + // Setup HTTP testing + _httpTest = new HttpTest(); + + // Mock BuildInfo.Version for consistent testing + typeof(BuildInfo).GetProperty(nameof(BuildInfo.Version))?.SetValue(null, new Version("0.5.0.0")); + } + + public void Dispose() + { + _httpTest.Dispose(); + + // Cleanup temp directory + if (Directory.Exists(_tempPath)) + { + Directory.Delete(_tempPath, true); + } + + // Reset BuildInfo.Version + typeof(BuildInfo).GetProperty(nameof(BuildInfo.Version))?.SetValue(null, null); + } + + [Fact] + public async Task CheckForUpdate_ShouldReturnNull_WhenGithubApiReturnsNull() + { + // Arrange + _httpTest.RespondWith("null"); + + // Act + var result = await _service.CheckForUpdate(); + + // Assert + Assert.Null(result); + } + + // Depends on BuildInfo.CurrentVersion + //[Fact] + public async Task CheckForUpdate_ShouldReturnUpdateNotification_WhenNewVersionIsAvailable() + { + // Arrange + var githubResponse = new + { + tag_name = "v0.6.0", + name = "Release 0.6.0", + body = "# Added\n- Feature 1\n- Feature 2\n# Fixed\n- Bug 1\n- Bug 2", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.6.0", + published_at = DateTime.UtcNow.ToString("o") + }; + + _httpTest.RespondWithJson(githubResponse); + + // Act + var result = await _service.CheckForUpdate(); + + // Assert + Assert.NotNull(result); + Assert.Equal("0.6.0", result.UpdateVersion); + Assert.Equal("0.5.0.0", result.CurrentVersion); + Assert.True(result.IsReleaseNewer); + Assert.Equal(2, result.Added.Count); + Assert.Equal(2, result.Fixed.Count); + } + + //[Fact] + public async Task CheckForUpdate_ShouldDetectEqualVersion() + { + // I can't figure this out + typeof(BuildInfo).GetProperty(nameof(BuildInfo.Version))?.SetValue(null, new Version("0.5.0.0")); + + + var githubResponse = new + { + tag_name = "v0.5.0", + name = "Release 0.5.0", + body = "# Added\n- Feature 1", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.5.0", + published_at = DateTime.UtcNow.ToString("o") + }; + + _httpTest.RespondWithJson(githubResponse); + + // Act + var result = await _service.CheckForUpdate(); + + // Assert + Assert.NotNull(result); + Assert.True(result.IsReleaseEqual); + Assert.False(result.IsReleaseNewer); + } + + + //[Fact] + public async Task PushUpdate_ShouldSendUpdateEvent_WhenNewerVersionAvailable() + { + // Arrange + var update = new UpdateNotificationDto + { + UpdateVersion = "0.6.0", + CurrentVersion = "0.5.0.0", + UpdateBody = "", + UpdateTitle = null, + UpdateUrl = null, + PublishDate = null + }; + + // Act + await _service.PushUpdate(update); + + // Assert + await _eventHub.Received(1).SendMessageAsync( + Arg.Is(MessageFactory.UpdateAvailable), + Arg.Any(), + Arg.Is(true) + ); + } + + [Fact] + public async Task PushUpdate_ShouldNotSendUpdateEvent_WhenVersionIsEqual() + { + // Arrange + var update = new UpdateNotificationDto + { + UpdateVersion = "0.5.0.0", + CurrentVersion = "0.5.0.0", + UpdateBody = "", + UpdateTitle = null, + UpdateUrl = null, + PublishDate = null + }; + + // Act + await _service.PushUpdate(update); + + // Assert + await _eventHub.DidNotReceive().SendMessageAsync( + Arg.Any(), + Arg.Any(), + Arg.Any() + ); + } + + [Fact] + public async Task GetAllReleases_ShouldReturnReleases_LimitedByCount() + { + // Arrange + var releases = new List + { + new + { + tag_name = "v0.7.0", + name = "Release 0.7.0", + body = "# Added\n- Feature A", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.7.0", + published_at = DateTime.UtcNow.AddDays(-1).ToString("o") + }, + new + { + tag_name = "v0.6.0", + name = "Release 0.6.0", + body = "# Added\n- Feature B", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.6.0", + published_at = DateTime.UtcNow.AddDays(-10).ToString("o") + }, + new + { + tag_name = "v0.5.0", + name = "Release 0.5.0", + body = "# Added\n- Feature C", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.5.0", + published_at = DateTime.UtcNow.AddDays(-20).ToString("o") + } + }; + + _httpTest.RespondWithJson(releases); + + // Act + var result = await _service.GetAllReleases(2); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("0.7.0.0", result[0].UpdateVersion); + Assert.Equal("0.6.0", result[1].UpdateVersion); + } + + [Fact] + public async Task GetAllReleases_ShouldUseCachedData_WhenCacheIsValid() + { + // Arrange + var releases = new List + { + new() + { + UpdateVersion = "0.6.0", + CurrentVersion = "0.5.0.0", + PublishDate = DateTime.UtcNow.AddDays(-10) + .ToString("o"), + UpdateBody = "", + UpdateTitle = null, + UpdateUrl = null + } + }; + releases.Add(new() + { + UpdateVersion = "0.7.0", + CurrentVersion = "0.5.0.0", + PublishDate = DateTime.UtcNow.AddDays(-1) + .ToString("o"), + UpdateBody = "", + UpdateTitle = null, + UpdateUrl = null + }); + + // Create cache file + var cacheFilePath = Path.Combine(_tempPath, "github_releases_cache.json"); + await File.WriteAllTextAsync(cacheFilePath, System.Text.Json.JsonSerializer.Serialize(releases)); + File.SetLastWriteTimeUtc(cacheFilePath, DateTime.UtcNow); // Ensure it's fresh + + // Act + var result = await _service.GetAllReleases(); + + // Assert + Assert.Equal(2, result.Count); + Assert.Empty(_httpTest.CallLog); // No HTTP calls made + } + + [Fact] + public async Task GetAllReleases_ShouldFetchNewData_WhenCacheIsExpired() + { + // Arrange + var releases = new List + { + new() + { + UpdateVersion = "0.6.0", + CurrentVersion = "0.5.0.0", + PublishDate = DateTime.UtcNow.AddDays(-10) + .ToString("o"), + UpdateBody = null, + UpdateTitle = null, + UpdateUrl = null + } + }; + + // Create expired cache file + var cacheFilePath = Path.Combine(_tempPath, "github_releases_cache.json"); + await File.WriteAllTextAsync(cacheFilePath, System.Text.Json.JsonSerializer.Serialize(releases)); + File.SetLastWriteTimeUtc(cacheFilePath, DateTime.UtcNow.AddHours(-2)); // Expired (older than 1 hour) + + // Setup HTTP response for new fetch + var newReleases = new List + { + new + { + tag_name = "v0.7.0", + name = "Release 0.7.0", + body = "# Added\n- Feature A", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.7.0", + published_at = DateTime.UtcNow.ToString("o") + } + }; + + _httpTest.RespondWithJson(newReleases); + + // Act + var result = await _service.GetAllReleases(); + + // Assert + Assert.Equal(1, result.Count); + Assert.Equal("0.7.0.0", result[0].UpdateVersion); + Assert.NotEmpty(_httpTest.CallLog); // HTTP call was made + } + + [Fact] + public async Task GetNumberOfReleasesBehind_ShouldReturnCorrectCount() + { + // Arrange + var releases = new List + { + new + { + tag_name = "v0.7.0", + name = "Release 0.7.0", + body = "# Added\n- Feature A", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.7.0", + published_at = DateTime.UtcNow.AddDays(-1).ToString("o") + }, + new + { + tag_name = "v0.6.0", + name = "Release 0.6.0", + body = "# Added\n- Feature B", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.6.0", + published_at = DateTime.UtcNow.AddDays(-10).ToString("o") + }, + new + { + tag_name = "v0.5.0", + name = "Release 0.5.0", + body = "# Added\n- Feature C", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.5.0", + published_at = DateTime.UtcNow.AddDays(-20).ToString("o") + } + }; + + _httpTest.RespondWithJson(releases); + + // Act + var result = await _service.GetNumberOfReleasesBehind(); + + // Assert + Assert.Equal(2 + 1, result); // Behind 0.7.0 and 0.6.0 - We have to add 1 because the current release is > 0.7.0 + } + + [Fact] + public async Task GetNumberOfReleasesBehind_ShouldReturnCorrectCount_WithNightlies() + { + // Arrange + var releases = new List + { + new + { + tag_name = "v0.7.1", + name = "Release 0.7.1", + body = "# Added\n- Feature A", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.7.1", + published_at = DateTime.UtcNow.AddDays(-1).ToString("o") + }, + new + { + tag_name = "v0.7.0", + name = "Release 0.7.0", + body = "# Added\n- Feature A", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.7.0", + published_at = DateTime.UtcNow.AddDays(-10).ToString("o") + }, + }; + + _httpTest.RespondWithJson(releases); + + // Act + var result = await _service.GetNumberOfReleasesBehind(); + + // Assert + Assert.Equal(2, result); // We have to add 1 because the current release is > 0.7.0 + } + + [Fact] + public async Task ParseReleaseBody_ShouldExtractSections() + { + // Arrange + var githubResponse = new + { + tag_name = "v0.6.0", + name = "Release 0.6.0", + body = "This is a great release with many improvements!\n\n# Added\n- Feature 1\n- Feature 2\n# Fixed\n- Bug 1\n- Bug 2\n# Changed\n- Change 1\n# Developer\n- Dev note 1", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.6.0", + published_at = DateTime.UtcNow.ToString("o") + }; + + _httpTest.RespondWithJson(githubResponse); + + // Act + var result = await _service.CheckForUpdate(); + + // Assert + Assert.NotNull(result); + Assert.Equal(2, result.Added.Count); + Assert.Equal(2, result.Fixed.Count); + Assert.Equal(1, result.Changed.Count); + Assert.Equal(1, result.Developer.Count); + Assert.Contains("This is a great release", result.BlogPart); + } + + [Fact] + public async Task GetAllReleases_ShouldHandleNightlyBuilds() + { + // Arrange + // Set BuildInfo.Version to a nightly build version + typeof(BuildInfo).GetProperty(nameof(BuildInfo.Version))?.SetValue(null, new Version("0.7.1.0")); + + // Mock regular releases + var releases = new List + { + new + { + tag_name = "v0.7.0", + name = "Release 0.7.0", + body = "# Added\n- Feature A", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.7.0", + published_at = DateTime.UtcNow.AddDays(-1).ToString("o") + }, + new + { + tag_name = "v0.6.0", + name = "Release 0.6.0", + body = "# Added\n- Feature B", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.6.0", + published_at = DateTime.UtcNow.AddDays(-10).ToString("o") + } + }; + + _httpTest.RespondWithJson(releases); + + // Mock commit info for develop branch + _httpTest.RespondWithJson(new List()); + + // Act + var result = await _service.GetAllReleases(); + + // Assert + Assert.NotNull(result); + Assert.True(result[0].IsOnNightlyInRelease); + } +} diff --git a/API/DTOs/Update/UpdateNotificationDto.cs b/API/DTOs/Update/UpdateNotificationDto.cs index 449adf131..2f9550746 100644 --- a/API/DTOs/Update/UpdateNotificationDto.cs +++ b/API/DTOs/Update/UpdateNotificationDto.cs @@ -40,8 +40,7 @@ public class UpdateNotificationDto /// /// Date of the publish /// - public required string PublishDate { get; set - ; } + public required string PublishDate { get; set; } /// /// Is the server on a nightly within this release /// diff --git a/API/Services/Tasks/VersionUpdaterService.cs b/API/Services/Tasks/VersionUpdaterService.cs index ce79b6f8a..96ef50fdd 100644 --- a/API/Services/Tasks/VersionUpdaterService.cs +++ b/API/Services/Tasks/VersionUpdaterService.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text.Json; using System.Text.RegularExpressions; using System.Threading.Tasks; using API.DTOs.Update; @@ -68,13 +69,19 @@ public partial class VersionUpdaterService : IVersionUpdaterService [GeneratedRegex(@"^\n*(.*?)\n+#{1,2}\s", RegexOptions.Singleline)] private static partial Regex BlogPartRegex(); private readonly string _cacheFilePath; + /// + /// The latest release cache + /// + private readonly string _cacheLatestReleaseFilePath; private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(1); + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; public VersionUpdaterService(ILogger logger, IEventHub eventHub, IDirectoryService directoryService) { _logger = logger; _eventHub = eventHub; _cacheFilePath = Path.Combine(directoryService.LongTermCacheDirectory, "github_releases_cache.json"); + _cacheLatestReleaseFilePath = Path.Combine(directoryService.LongTermCacheDirectory, "github_latest_release_cache.json"); FlurlConfiguration.ConfigureClientForUrl(GithubLatestReleasesUrl); FlurlConfiguration.ConfigureClientForUrl(GithubAllReleasesUrl); @@ -86,9 +93,21 @@ public partial class VersionUpdaterService : IVersionUpdaterService /// Latest update public async Task CheckForUpdate() { + // Attempt to fetch from cache + var cachedRelease = await TryGetCachedLatestRelease(); + if (cachedRelease != null) + { + return cachedRelease; + } + var update = await GetGithubRelease(); var dto = CreateDto(update); + if (dto != null) + { + await CacheLatestReleaseAsync(dto); + } + return dto; } @@ -273,6 +292,13 @@ public partial class VersionUpdaterService : IVersionUpdaterService var updateDtos = query.ToList(); + // Sometimes a release can be 0.8.5.0 on disk, but 0.8.5 from Github + var versionParts = updateDtos[0].UpdateVersion.Split('.'); + if (versionParts.Length < 4) + { + updateDtos[0].UpdateVersion += ".0"; // Append missing parts + } + // If we're on a nightly build, enrich the information if (updateDtos.Count != 0 && BuildInfo.Version > new Version(updateDtos[0].UpdateVersion)) { @@ -321,11 +347,25 @@ public partial class VersionUpdaterService : IVersionUpdaterService return null; } + private async Task TryGetCachedLatestRelease() + { + if (!File.Exists(_cacheLatestReleaseFilePath)) return null; + + var fileInfo = new FileInfo(_cacheLatestReleaseFilePath); + if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc <= CacheDuration) + { + var cachedData = await File.ReadAllTextAsync(_cacheLatestReleaseFilePath); + return System.Text.Json.JsonSerializer.Deserialize(cachedData); + } + + return null; + } + private async Task CacheReleasesAsync(IList updates) { try { - var json = System.Text.Json.JsonSerializer.Serialize(updates, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + var json = System.Text.Json.JsonSerializer.Serialize(updates, JsonOptions); await File.WriteAllTextAsync(_cacheFilePath, json); } catch (Exception ex) @@ -334,6 +374,19 @@ public partial class VersionUpdaterService : IVersionUpdaterService } } + private async Task CacheLatestReleaseAsync(UpdateNotificationDto update) + { + try + { + var json = System.Text.Json.JsonSerializer.Serialize(update, JsonOptions); + await File.WriteAllTextAsync(_cacheLatestReleaseFilePath, json); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to cache latest release"); + } + } + private static bool IsVersionEqualToBuildVersion(Version updateVersion) @@ -346,7 +399,10 @@ public partial class VersionUpdaterService : IVersionUpdaterService public async Task GetNumberOfReleasesBehind() { var updates = await GetAllReleases(); - return updates.TakeWhile(update => update.UpdateVersion != update.CurrentVersion).Count(); + return updates + .Where(update => !update.IsPrerelease) + .TakeWhile(update => update.UpdateVersion != update.CurrentVersion) + .Count(); } private UpdateNotificationDto? CreateDto(GithubReleaseMetadata? update) @@ -370,6 +426,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService PublishDate = update.Published_At, IsReleaseEqual = IsVersionEqualToBuildVersion(updateVersion), IsReleaseNewer = BuildInfo.Version < updateVersion, + IsPrerelease = false, Added = parsedSections.TryGetValue("Added", out var added) ? added : [], Removed = parsedSections.TryGetValue("Removed", out var removed) ? removed : [], diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index 2e7bd1cdd..d7e3635b4 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -3,7 +3,7 @@ net9.0 kavitareader.com Kavita - 0.8.5.0 + 0.8.5.1 en true true @@ -21,4 +21,4 @@ - \ No newline at end of file + diff --git a/UI/Web/src/app/_models/wiki.ts b/UI/Web/src/app/_models/wiki.ts index 511f9f58c..a94e0f7db 100644 --- a/UI/Web/src/app/_models/wiki.ts +++ b/UI/Web/src/app/_models/wiki.ts @@ -19,6 +19,6 @@ export enum WikiLink { Library = 'https://wiki.kavitareader.com/guides/admin-settings/libraries', UpdateNative = 'https://wiki.kavitareader.com/guides/updating/updating-native', UpdateDocker = 'https://wiki.kavitareader.com/guides/updating/updating-docker', - OpdsClients = 'https://wiki.kavitareader.com/guides/opds#opds-capable-clients', + OpdsClients = 'https://wiki.kavitareader.com/guides/features/opds/#opds-capable-clients', Guides = 'https://wiki.kavitareader.com/guides' } diff --git a/UI/Web/src/app/admin/manage-media-issues/manage-media-issues.component.ts b/UI/Web/src/app/admin/manage-media-issues/manage-media-issues.component.ts index 2ff70e7a7..f1238748c 100644 --- a/UI/Web/src/app/admin/manage-media-issues/manage-media-issues.component.ts +++ b/UI/Web/src/app/admin/manage-media-issues/manage-media-issues.component.ts @@ -18,8 +18,7 @@ import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import { FilterPipe } from '../../_pipes/filter.pipe'; -import { LoadingComponent } from '../../shared/loading/loading.component'; -import {translate, TranslocoDirective} from "@jsverse/transloco"; +import {TranslocoDirective} from "@jsverse/transloco"; import {WikiLink} from "../../_models/wiki"; import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; import {DefaultDatePipe} from "../../_pipes/default-date.pipe"; @@ -31,10 +30,12 @@ import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; styleUrls: ['./manage-media-issues.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [ReactiveFormsModule, LoadingComponent, FilterPipe, SortableHeader, TranslocoDirective, UtcToLocalTimePipe, DefaultDatePipe, NgxDatatableModule] + imports: [ReactiveFormsModule, FilterPipe, TranslocoDirective, UtcToLocalTimePipe, DefaultDatePipe, NgxDatatableModule] }) export class ManageMediaIssuesComponent implements OnInit { + protected readonly ColumnMode = ColumnMode; + @Output() alertCount = new EventEmitter(); @ViewChildren(SortableHeader) headers!: QueryList>; @@ -70,17 +71,6 @@ export class ManageMediaIssuesComponent implements OnInit { }); } - onSort(evt: any) { - //SortEvent - this.currentSort.next(evt); - - // Must clear out headers here - this.headers.forEach((header) => { - if (header.sortable !== evt.column) { - header.direction = ''; - } - }); - } loadData() { this.isLoading = true; @@ -101,6 +91,5 @@ export class ManageMediaIssuesComponent implements OnInit { const query = (this.formGroup.get('filter')?.value || '').toLowerCase(); return listItem.comment.toLowerCase().indexOf(query) >= 0 || listItem.filePath.toLowerCase().indexOf(query) >= 0 || listItem.details.indexOf(query) >= 0; } - protected readonly ColumnMode = ColumnMode; - protected readonly translate = translate; + }