From 547dd2672bbc41ddf92b719de8b540b80426fc8e Mon Sep 17 00:00:00 2001 From: tech nasty <262050521+t3chnaztea@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:00:54 -0500 Subject: [PATCH] fix(library): seed a default quality profile and reject invalid profile ids Monitored audiobooks silently never grabbed on fresh installs. Three compounding gaps: no default quality profile was ever seeded, so GetDefaultAsync returned null; the add path then persisted the audiobook with QualityProfileId = null anyway (and accepted a caller-supplied id without checking it exists); and the automatic-search query filters QualityProfileId != null, so those books were never searched and nothing surfaced the problem. - Seed a permissive "Any Quality" default profile at startup when the QualityProfiles table is empty, via the existing StartupDbNormalizer hosted service (always registered, even when other hosted services are disabled). Idempotent: only seeds when no profile exists. Normalization and seeding are now guarded independently so a normalization failure no longer skips the seed. Shape built by QualityProfile.CreateDefault(). - Reject the add when a supplied QualityProfileId does not exist, or when no profile is supplied and no default can be resolved, instead of persisting a null-profile row. Surfaced as a 400 (BadRequest) through LibraryAddWorkflow; the primary LibraryAddService path reports this via a new LibraryAddOperationResult.Rejected flag, validated before any persistence-side work. The automatic-search query (QualityProfileId != null) is unchanged: with seeding + rejection the null-profile state can no longer arise for new adds. Existing rows already written with a null quality profile still need manual profile assignment (or a follow-up normalizer) before they will be searched. Co-Authored-By: Claude Fable 5 --- .../Features/Library/LibraryAddWorkflow.cs | 48 ++++++--- .../Audiobooks/Catalog/LibraryAddService.cs | 66 +++++++++---- .../Contracts/ILibraryAddService.cs | 6 ++ .../Repositories/IQualityProfileRepository.cs | 6 ++ listenarr.domain/Audiobooks/QualityProfile.cs | 29 ++++++ .../Repositories/QualityProfileRepository.cs | 12 +++ .../Persistence/StartupDbNormalizer.cs | 32 +++++- .../LibraryController_AddToLibraryTests.cs | 3 + ...ontroller_QualityProfileValidationTests.cs | 95 ++++++++++++++++++ .../SeriesMonitoringServiceTests.cs | 3 + .../Persistence/QualityProfileSeedTests.cs | 98 +++++++++++++++++++ .../StartupDbNormalizerSeedTests.cs | 49 ++++++++++ 12 files changed, 413 insertions(+), 34 deletions(-) create mode 100644 tests/Features/Api/Features/Library/LibraryController_QualityProfileValidationTests.cs create mode 100644 tests/Features/Infrastructure/Persistence/QualityProfileSeedTests.cs create mode 100644 tests/Features/Infrastructure/Persistence/StartupDbNormalizerSeedTests.cs diff --git a/listenarr.api/Features/Library/LibraryAddWorkflow.cs b/listenarr.api/Features/Library/LibraryAddWorkflow.cs index dfcd9a7bc..5e048c605 100644 --- a/listenarr.api/Features/Library/LibraryAddWorkflow.cs +++ b/listenarr.api/Features/Library/LibraryAddWorkflow.cs @@ -67,6 +67,11 @@ public async Task AddAsync(LibraryController.AddToLibraryRequest HistoryMessage = $"Audiobook '{request.Metadata.Title}' added to library from Add New page" }); + if (result.Rejected) + { + return new BadRequestObjectResult(new { message = result.Message }); + } + if (result.AlreadyExists) { return new ConflictObjectResult(new { message = result.Message, audiobook = result.Audiobook }); @@ -129,7 +134,11 @@ public async Task AddAsync(LibraryController.AddToLibraryRequest _logger.LogInformation("Created Audiobook entity: Title={Title}, Asin={Asin}, PublishYear={PublishYear}", LogRedaction.SanitizeText(audiobook.Title), LogRedaction.SanitizeText(audiobook.Asin), LogRedaction.SanitizeText(audiobook.PublishYear)); - await AssignQualityProfileAsync(audiobook, request); + var profileError = await AssignQualityProfileAsync(audiobook, request); + if (profileError != null) + { + return profileError; + } if (!string.IsNullOrWhiteSpace(request.DestinationPath)) { @@ -251,29 +260,38 @@ private void TryExtractPublishYear(LibraryController.AddToLibraryRequest request return fallbackImageUrl; } - private async Task AssignQualityProfileAsync(Audiobook audiobook, LibraryController.AddToLibraryRequest request) + private async Task AssignQualityProfileAsync(Audiobook audiobook, LibraryController.AddToLibraryRequest request) { + using var scope = _scopeFactory.CreateScope(); + var qualityProfileService = scope.ServiceProvider.GetRequiredService(); + if (request.QualityProfileId.HasValue) { - audiobook.QualityProfileId = request.QualityProfileId.Value; + var suppliedProfile = await qualityProfileService.GetByIdAsync(request.QualityProfileId.Value); + if (suppliedProfile == null) + { + _logger.LogWarning("Rejected library add for '{Title}': quality profile {ProfileId} does not exist.", + LogRedaction.SanitizeText(audiobook.Title), request.QualityProfileId.Value); + return new BadRequestObjectResult(new { message = $"Quality profile with ID {request.QualityProfileId.Value} does not exist." }); + } + + audiobook.QualityProfileId = suppliedProfile.Id; _logger.LogInformation("Assigned custom quality profile ID {ProfileId} to new audiobook '{Title}'", - request.QualityProfileId.Value, LogRedaction.SanitizeText(audiobook.Title)); - return; + suppliedProfile.Id, LogRedaction.SanitizeText(audiobook.Title)); + return null; } - using var scope = _scopeFactory.CreateScope(); - var qualityProfileService = scope.ServiceProvider.GetRequiredService(); var defaultProfile = await qualityProfileService.GetDefaultAsync(); - if (defaultProfile != null) - { - audiobook.QualityProfileId = defaultProfile.Id; - _logger.LogInformation("Assigned default quality profile '{ProfileName}' (ID: {ProfileId}) to new audiobook '{Title}'", - defaultProfile.Name, defaultProfile.Id, audiobook.Title); - } - else + if (defaultProfile == null) { - _logger.LogWarning("No default quality profile found. New audiobook '{Title}' will not have a quality profile assigned.", LogRedaction.SanitizeText(audiobook.Title)); + _logger.LogWarning("Rejected library add for '{Title}': no quality profile supplied and no default profile is configured.", LogRedaction.SanitizeText(audiobook.Title)); + return new BadRequestObjectResult(new { message = "No quality profile was supplied and no default quality profile is configured." }); } + + audiobook.QualityProfileId = defaultProfile.Id; + _logger.LogInformation("Assigned default quality profile '{ProfileName}' (ID: {ProfileId}) to new audiobook '{Title}'", + defaultProfile.Name, defaultProfile.Id, audiobook.Title); + return null; } private async Task ResolveAuthorAsinsAsync(Audiobook audiobook) diff --git a/listenarr.application/Audiobooks/Catalog/LibraryAddService.cs b/listenarr.application/Audiobooks/Catalog/LibraryAddService.cs index af3cce394..c4a274a78 100644 --- a/listenarr.application/Audiobooks/Catalog/LibraryAddService.cs +++ b/listenarr.application/Audiobooks/Catalog/LibraryAddService.cs @@ -119,6 +119,14 @@ public async Task AddToLibraryAsync( } } + // Resolve and validate the quality profile before doing any persistence-side work. + // A profile-less audiobook is never searched, so refuse the add instead of storing it silently. + var resolvedProfile = await ResolveQualityProfileAsync(request, metadata.Title); + if (resolvedProfile.Rejected) + { + return resolvedProfile.Result!; + } + var imageUrl = await MoveImageToLibraryStorageAsync(metadata, request.SearchResult, firstIsbn); var audiobook = metadata.ToAudiobook(); @@ -128,24 +136,7 @@ public async Task AddToLibraryAsync( AudiobookIdentifierMapper.SyncImportedIdentifiersFromLegacyFields(audiobook, metadata.Region); - if (request.QualityProfileId.HasValue) - { - audiobook.QualityProfileId = request.QualityProfileId.Value; - } - else - { - var defaultProfile = await _qualityProfileService.GetDefaultAsync(); - if (defaultProfile != null) - { - audiobook.QualityProfileId = defaultProfile.Id; - } - else - { - _logger.LogWarning( - "No default quality profile found. New audiobook '{Title}' will not have a quality profile assigned.", - audiobook.Title); - } - } + audiobook.QualityProfileId = resolvedProfile.ProfileId; var settings = await _configurationService.GetApplicationSettingsAsync(); @@ -198,6 +189,45 @@ public async Task AddToLibraryAsync( }; } + private async Task<(bool Rejected, int ProfileId, LibraryAddOperationResult? Result)> ResolveQualityProfileAsync( + LibraryAddOperationRequest request, + string? title) + { + if (request.QualityProfileId.HasValue) + { + var suppliedProfile = await _qualityProfileService.GetByIdAsync(request.QualityProfileId.Value); + if (suppliedProfile == null) + { + _logger.LogWarning( + "Rejected library add for '{Title}': quality profile {ProfileId} does not exist.", + title, + request.QualityProfileId.Value); + return (true, 0, new LibraryAddOperationResult + { + Rejected = true, + Message = $"Quality profile with ID {request.QualityProfileId.Value} does not exist." + }); + } + + return (false, suppliedProfile.Id, null); + } + + var defaultProfile = await _qualityProfileService.GetDefaultAsync(); + if (defaultProfile == null) + { + _logger.LogWarning( + "Rejected library add for '{Title}': no quality profile supplied and no default profile is configured.", + title); + return (true, 0, new LibraryAddOperationResult + { + Rejected = true, + Message = "No quality profile was supplied and no default quality profile is configured." + }); + } + + return (false, defaultProfile.Id, null); + } + private async Task MoveImageToLibraryStorageAsync( AudibleBookMetadata metadata, SearchResult? searchResult, diff --git a/listenarr.application/Audiobooks/Contracts/ILibraryAddService.cs b/listenarr.application/Audiobooks/Contracts/ILibraryAddService.cs index 890108c21..9ca66ab2a 100644 --- a/listenarr.application/Audiobooks/Contracts/ILibraryAddService.cs +++ b/listenarr.application/Audiobooks/Contracts/ILibraryAddService.cs @@ -50,6 +50,12 @@ public sealed class LibraryAddOperationResult public bool AlreadyExists { get; set; } + /// + /// True when the add was refused (e.g. an invalid quality profile id, or no profile + /// could be resolved). The caller should surface this as a 400-style validation error. + /// + public bool Rejected { get; set; } + public string Message { get; set; } = string.Empty; public Audiobook? Audiobook { get; set; } diff --git a/listenarr.application/Audiobooks/Contracts/Repositories/IQualityProfileRepository.cs b/listenarr.application/Audiobooks/Contracts/Repositories/IQualityProfileRepository.cs index 18303984a..3135f9595 100644 --- a/listenarr.application/Audiobooks/Contracts/Repositories/IQualityProfileRepository.cs +++ b/listenarr.application/Audiobooks/Contracts/Repositories/IQualityProfileRepository.cs @@ -27,5 +27,11 @@ public interface IQualityProfileRepository Task UpdateAsync(QualityProfile profile); Task DeleteAsync(int id); Task CountAudiobooksUsingProfileAsync(int profileId); + + /// + /// Seeds a single default quality profile when the table is empty. Idempotent: + /// does nothing when any profile already exists. Returns true if a profile was seeded. + /// + Task SeedDefaultProfileIfMissingAsync(CancellationToken cancellationToken = default); } } diff --git a/listenarr.domain/Audiobooks/QualityProfile.cs b/listenarr.domain/Audiobooks/QualityProfile.cs index ca11730a5..57138a754 100644 --- a/listenarr.domain/Audiobooks/QualityProfile.cs +++ b/listenarr.domain/Audiobooks/QualityProfile.cs @@ -112,6 +112,35 @@ public class QualityProfile public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + /// + /// Builds the permissive "Any Quality" profile used to seed a fresh install so that + /// monitored audiobooks always resolve a quality profile and become searchable. + /// Every codec/bitrate rung is allowed and there is no cutoff, so no release is rejected on quality. + /// + public static QualityProfile CreateDefault() + { + return new QualityProfile + { + Name = "Any Quality", + Description = "Default profile seeded automatically. Accepts any audio quality.", + IsDefault = true, + Qualities = new List + { + new() { Quality = "AAC 320kbps", Allowed = true, Priority = 0, Codec = "AAC", Bitrate = 320 }, + new() { Quality = "AAC 256kbps", Allowed = true, Priority = 1, Codec = "AAC", Bitrate = 256 }, + new() { Quality = "AAC 192kbps", Allowed = true, Priority = 2, Codec = "AAC", Bitrate = 192 }, + new() { Quality = "AAC 128kbps", Allowed = true, Priority = 3, Codec = "AAC", Bitrate = 128 }, + new() { Quality = "AAC 64kbps", Allowed = true, Priority = 4, Codec = "AAC", Bitrate = 64 }, + new() { Quality = "MP3 320kbps", Allowed = true, Priority = 5, Codec = "MP3", Bitrate = 320 }, + new() { Quality = "MP3 256kbps", Allowed = true, Priority = 6, Codec = "MP3", Bitrate = 256 }, + new() { Quality = "MP3 VBR", Allowed = true, Priority = 7, Codec = "MP3" }, + new() { Quality = "MP3 192kbps", Allowed = true, Priority = 8, Codec = "MP3", Bitrate = 192 }, + new() { Quality = "MP3 128kbps", Allowed = true, Priority = 9, Codec = "MP3", Bitrate = 128 }, + new() { Quality = "MP3 64kbps", Allowed = true, Priority = 10, Codec = "MP3", Bitrate = 64 }, + } + }; + } } /// diff --git a/listenarr.infrastructure/Persistence/Repositories/QualityProfileRepository.cs b/listenarr.infrastructure/Persistence/Repositories/QualityProfileRepository.cs index ad792dbda..6b19421a9 100644 --- a/listenarr.infrastructure/Persistence/Repositories/QualityProfileRepository.cs +++ b/listenarr.infrastructure/Persistence/Repositories/QualityProfileRepository.cs @@ -128,5 +128,17 @@ public async Task CountAudiobooksUsingProfileAsync(int profileId) { return await _db.Audiobooks.CountAsync(a => a.QualityProfileId == profileId); } + + public async Task SeedDefaultProfileIfMissingAsync(CancellationToken cancellationToken = default) + { + if (await _db.QualityProfiles.AnyAsync(cancellationToken)) + { + return false; + } + + _db.QualityProfiles.Add(QualityProfile.CreateDefault()); + await _db.SaveChangesAsync(cancellationToken); + return true; + } } } diff --git a/listenarr.infrastructure/Persistence/StartupDbNormalizer.cs b/listenarr.infrastructure/Persistence/StartupDbNormalizer.cs index 1e5faf2bf..ef7bbb046 100644 --- a/listenarr.infrastructure/Persistence/StartupDbNormalizer.cs +++ b/listenarr.infrastructure/Persistence/StartupDbNormalizer.cs @@ -38,10 +38,19 @@ public StartupDbNormalizer(IServiceProvider provider, ILogger(); await audiobookRepository.NormalizeJsonColumnsAsync(cancellationToken); _logger.LogInformation("StartupDbNormalizer: normalization pass complete."); @@ -60,6 +69,27 @@ public async Task StartAsync(CancellationToken cancellationToken) } } + private async Task SeedDefaultQualityProfileAsync(IServiceScope scope, CancellationToken cancellationToken) + { + try + { + var qualityProfileRepository = scope.ServiceProvider.GetRequiredService(); + var seededDefaultProfile = await qualityProfileRepository.SeedDefaultProfileIfMissingAsync(cancellationToken); + if (seededDefaultProfile) + { + _logger.LogInformation("StartupDbNormalizer: seeded default quality profile (none existed)."); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + Debug.WriteLine("Suppressed non-fatal exception in catch block."); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogError(ex, "StartupDbNormalizer: unexpected error while seeding default quality profile"); + } + } + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; } } diff --git a/tests/Features/Api/Features/Library/LibraryController_AddToLibraryTests.cs b/tests/Features/Api/Features/Library/LibraryController_AddToLibraryTests.cs index 98bdc6dd3..a2906fab0 100644 --- a/tests/Features/Api/Features/Library/LibraryController_AddToLibraryTests.cs +++ b/tests/Features/Api/Features/Library/LibraryController_AddToLibraryTests.cs @@ -50,6 +50,9 @@ await _rootFolderRepository.AddAsync(new RootFolderBuilder() .WithIsDefault() .WithPath(tempRoot) .Build()); + + // Adds now require a resolvable quality profile; production seeds this at startup. + await _qualityProfileRepository.AddAsync(QualityProfile.CreateDefault()); } [Fact] diff --git a/tests/Features/Api/Features/Library/LibraryController_QualityProfileValidationTests.cs b/tests/Features/Api/Features/Library/LibraryController_QualityProfileValidationTests.cs new file mode 100644 index 000000000..1259c53ee --- /dev/null +++ b/tests/Features/Api/Features/Library/LibraryController_QualityProfileValidationTests.cs @@ -0,0 +1,95 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using Microsoft.AspNetCore.Mvc; +using Listenarr.Tests.Common; +using Listenarr.Tests.Builders; + +namespace Listenarr.Tests.Features.Api.Features.Library +{ + public class LibraryController_QualityProfileValidationTests : BaseTests + { + private readonly Mock _imageCacheServiceMock = new(); + private string _tempRoot = null!; + private int _defaultProfileId; + + public override async Task InitializeAsync() + { + _imageCacheServiceMock + .Setup(m => m.MoveToLibraryStorageAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((string?)null); + + Init(services => services.WithSingleton(_imageCacheServiceMock.Object)); + + _tempRoot = FileService.GetTempDirectory("listenarr-test"); + + await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() + .WithFolderNamingPattern("{Author}") + .WithFileNamingPattern("{Title}") + .Build()); + + await _rootFolderRepository.AddAsync(new RootFolderBuilder() + .WithIsDefault() + .WithPath(_tempRoot) + .Build()); + + var defaultProfile = await _qualityProfileRepository.AddAsync(QualityProfile.CreateDefault()); + _defaultProfileId = defaultProfile.Id; + } + + [Fact] + public async Task AddToLibrary_WithBogusQualityProfileId_ReturnsBadRequestAndPersistsNothing() + { + var controller = _provider.GetRequiredService(); + + var request = new LibraryController.AddToLibraryRequest + { + Metadata = new AudibleBookMetadata + { + Title = "Bogus Profile Title", + Author = "Some Author" + }, + Monitored = true, + QualityProfileId = 999999 + }; + + // Act + var actionResult = await controller.AddToLibrary(request); + + // Assert + Assert.IsType(actionResult); + Assert.Empty(await _audiobookRepository.GetAllAsync()); + } + + [Fact] + public async Task AddToLibrary_WithNoQualityProfileId_AssignsDefaultAndPersists() + { + var controller = _provider.GetRequiredService(); + + var request = new LibraryController.AddToLibraryRequest + { + Metadata = new AudibleBookMetadata + { + Title = "Default Profile Title", + Author = "Some Author" + }, + Monitored = true + }; + + // Act + var actionResult = await controller.AddToLibrary(request); + + // Assert + Assert.IsType(actionResult); + var stored = Assert.Single(await _audiobookRepository.GetAllAsync()); + Assert.Equal(_defaultProfileId, stored.QualityProfileId); + } + } +} diff --git a/tests/Features/Application/Audiobooks/Monitoring/SeriesMonitoringServiceTests.cs b/tests/Features/Application/Audiobooks/Monitoring/SeriesMonitoringServiceTests.cs index 88a46566a..de2309112 100644 --- a/tests/Features/Application/Audiobooks/Monitoring/SeriesMonitoringServiceTests.cs +++ b/tests/Features/Application/Audiobooks/Monitoring/SeriesMonitoringServiceTests.cs @@ -146,6 +146,9 @@ await _rootFolderRepository.AddAsync(new RootFolderBuilder() .WithPath(rootPath) .Build()); + // Adds now require a resolvable quality profile; production seeds this at startup. + await _qualityProfileRepository.AddAsync(QualityProfile.CreateDefault()); + _seriesCatalogService .Setup(service => service.GetCatalogAsync( "Dungeon Crawler Carl", diff --git a/tests/Features/Infrastructure/Persistence/QualityProfileSeedTests.cs b/tests/Features/Infrastructure/Persistence/QualityProfileSeedTests.cs new file mode 100644 index 000000000..b478dbe4e --- /dev/null +++ b/tests/Features/Infrastructure/Persistence/QualityProfileSeedTests.cs @@ -0,0 +1,98 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using Listenarr.Infrastructure.Persistence.Repositories; +using Listenarr.Tests.Builders; +using Microsoft.EntityFrameworkCore; + +namespace Listenarr.Tests.Features.Infrastructure.Persistence; + +public sealed class QualityProfileSeedTests : IAsyncLifetime +{ + private readonly string _databasePath = + Path.Join(Path.GetTempPath(), "listenarr-tests", $"quality-seed-{Guid.NewGuid():N}.db"); + private DbContextOptions _options = null!; + + public async Task InitializeAsync() + { + Directory.CreateDirectory(Path.GetDirectoryName(_databasePath)!); + _options = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={_databasePath};Pooling=False") + .Options; + await using var db = new ListenArrDbContext(_options); + await db.Database.EnsureCreatedAsync(); + } + + public Task DisposeAsync() + { + if (File.Exists(_databasePath)) + { + File.Delete(_databasePath); + } + + return Task.CompletedTask; + } + + [Fact] + public async Task SeedDefaultProfile_WhenTableEmpty_CreatesExactlyOneDefaultProfile() + { + // Given an empty QualityProfiles table + await using var db = new ListenArrDbContext(_options); + var repository = new QualityProfileRepository(db); + + // When seeding + var seeded = await repository.SeedDefaultProfileIfMissingAsync(); + + // Then exactly one default profile exists + Assert.True(seeded); + var profiles = await repository.GetAllAsync(); + var profile = Assert.Single(profiles); + Assert.True(profile.IsDefault); + Assert.Equal("Any Quality", profile.Name); + Assert.NotEmpty(profile.Qualities); + Assert.All(profile.Qualities, quality => Assert.True(quality.Allowed)); + } + + [Fact] + public async Task SeedDefaultProfile_RunTwice_RemainsExactlyOneProfile() + { + // Given a fresh database seeded once + await using var db = new ListenArrDbContext(_options); + var repository = new QualityProfileRepository(db); + Assert.True(await repository.SeedDefaultProfileIfMissingAsync()); + + // When seeding again + var seededSecond = await repository.SeedDefaultProfileIfMissingAsync(); + + // Then nothing is added and exactly one profile remains (idempotent) + Assert.False(seededSecond); + Assert.Single(await repository.GetAllAsync()); + } + + [Fact] + public async Task SeedDefaultProfile_WhenProfilesExist_SeedsNothing() + { + // Given a table that already has a (non-default) profile + await using var db = new ListenArrDbContext(_options); + var repository = new QualityProfileRepository(db); + await repository.AddAsync(new QualityProfileBuilder() + .WithName("Custom Only") + .Build()); + + // When seeding + var seeded = await repository.SeedDefaultProfileIfMissingAsync(); + + // Then no seed happens and the original profile is untouched + Assert.False(seeded); + var profile = Assert.Single(await repository.GetAllAsync()); + Assert.Equal("Custom Only", profile.Name); + Assert.False(profile.IsDefault); + } +} diff --git a/tests/Features/Infrastructure/Persistence/StartupDbNormalizerSeedTests.cs b/tests/Features/Infrastructure/Persistence/StartupDbNormalizerSeedTests.cs new file mode 100644 index 000000000..03f5f84c8 --- /dev/null +++ b/tests/Features/Infrastructure/Persistence/StartupDbNormalizerSeedTests.cs @@ -0,0 +1,49 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using Listenarr.Tests.Common; + +namespace Listenarr.Tests.Features.Infrastructure.Persistence +{ + public sealed class StartupDbNormalizerSeedTests : BaseTests + { + [Fact] + public async Task StartAsync_OnFreshDatabase_SeedsExactlyOneDefaultProfile() + { + // Given a fresh database with no quality profiles + Assert.Empty(await _qualityProfileRepository.GetAllAsync()); + var normalizer = new StartupDbNormalizer( + _provider, + _provider.GetRequiredService>()); + + // When the startup normalizer runs + await normalizer.StartAsync(CancellationToken.None); + + // Then exactly one default profile has been seeded + var profile = Assert.Single(await _qualityProfileRepository.GetAllAsync()); + Assert.True(profile.IsDefault); + } + + [Fact] + public async Task StartAsync_RunTwice_RemainsExactlyOneProfile() + { + var normalizer = new StartupDbNormalizer( + _provider, + _provider.GetRequiredService>()); + + // When the startup normalizer runs twice + await normalizer.StartAsync(CancellationToken.None); + await normalizer.StartAsync(CancellationToken.None); + + // Then seeding stays idempotent + Assert.Single(await _qualityProfileRepository.GetAllAsync()); + } + } +}