Changed prose.
"; await candidate.Db.SaveChangesAsync(); }); ShouldFail(report, "writing", nameof(WritingModel.Content)); } [Fact] public async Task Writing_citation_index_is_neither_required_nor_counted() { // Issue #1162: the index is derived; the verifier compares portable // state only, so an extra or a missing row never fails a candidate. var report = await VerifyAsync(async candidate => { candidate.Db.WritingCitations.Add(new WritingCitationModel { WritingId = _fixture.WritingDocumentId, NoteId = _fixture.NoteId, }); await candidate.Db.SaveChangesAsync(); }); report.Passed.Should().BeTrue( string.Join("; ", report.Failures.Select(failure => $"{failure.Code}:{failure.Entity}:{failure.Field}"))); PortableLibraryVerifier.CandidateVerifiedKinds.Should().NotContain(kind => kind.Contains("Citation", StringComparison.OrdinalIgnoreCase)); } [Fact] public async Task Writing_note_timestamp_change_is_detected() { var report = await VerifyAsync(async candidate => { var link = await candidate.Db.WritingNotes.SingleAsync( item => item.WritingId == _fixture.WritingDocumentId); link.AddedAt = link.AddedAt.AddDays(1); await candidate.Db.SaveChangesAsync(); }); ShouldFail(report, "writingNote", nameof(WritingNoteModel.AddedAt)); } [Fact] public async Task Work_title_change_is_detected() { var report = await VerifyAsync(async candidate => { var work = await candidate.Db.Works.SingleAsync(item => item.Id == _fixture.WorkId); work.Title = "Changed work"; await candidate.Db.SaveChangesAsync(); }); ShouldFail(report, "work", nameof(WorkModel.Title)); } [Fact] public async Task Acquisition_field_change_is_detected() { var report = await VerifyAsync(async candidate => { var acquisition = await candidate.Db.BookAcquisitions.SingleAsync( item => item.Id == _fixture.AcquisitionId); acquisition.ExternalId = "changed-external"; await candidate.Db.SaveChangesAsync(); }); ShouldFail(report, "acquisition", nameof(BookAcquisitionModel.ExternalId)); } [Fact] public async Task Note_import_book_link_change_is_detected() { var report = await VerifyAsync(async candidate => { var link = await candidate.Db.NoteImportBookLinks.SingleAsync( item => item.Id == _fixture.ImportLinkId); link.SourceKey = "changed-source-key"; await candidate.Db.SaveChangesAsync(); }); ShouldFail(report, "noteImportBookLink", nameof(NoteImportBookLink.SourceKey)); } [Fact] public async Task Assistant_setting_change_is_detected() { var report = await VerifyAsync(async candidate => { var row = await candidate.Db.AssistantSettings.SingleAsync(); row.CaptureProcessingMode = "clarify"; await candidate.Db.SaveChangesAsync(); }); ShouldFail(report, "assistantSettings", nameof(AssistantSettingsModel.CaptureProcessingMode)); } [Fact] public async Task Extra_portable_row_is_detected() { var report = await VerifyAsync(async candidate => { candidate.Db.Topics.Add(new TopicModel { Topic = "unexpected-topic" }); await candidate.Db.SaveChangesAsync(); }); report.Passed.Should().BeFalse(); report.Failures.Should().Contain(failure => failure.Code == PortableLibraryVerificationErrorCodes.UnexpectedEntity && failure.Entity == "topic"); report.Failures.Should().Contain(failure => failure.Code == PortableLibraryVerificationErrorCodes.CountMismatch && failure.Field == nameof(MigrationArchiveCounts.Topics)); } [Fact] public async Task Missing_portable_row_is_detected() { var report = await VerifyAsync(async candidate => { var link = await candidate.Db.NoteTopics.SingleAsync(); candidate.Db.NoteTopics.Remove(link); var topic = await candidate.Db.Topics.SingleAsync(); candidate.Db.Topics.Remove(topic); await candidate.Db.SaveChangesAsync(); }); report.Passed.Should().BeFalse(); report.Failures.Should().Contain(failure => failure.Code == PortableLibraryVerificationErrorCodes.MissingEntity && failure.Entity == "topic"); } [Fact] public async Task Broken_note_book_relationship_is_detected() { var report = await VerifyAsync(async candidate => { var note = await candidate.Db.Notes.SingleAsync(item => item.Id == _fixture.NoteId); note.BookId = _fixture.AudioBookId; await candidate.Db.SaveChangesAsync(); }); ShouldFail(report, "note", nameof(NoteModel.BookId)); } [Fact] public async Task Corrupt_candidate_media_byte_keeping_length_is_detected() { var report = await VerifyAsync(candidate => { var path = MediaPath(candidate, 0); var bytes = File.ReadAllBytes(path); bytes[0] ^= 0xff; File.WriteAllBytes(path, bytes); return Task.CompletedTask; }); report.Passed.Should().BeFalse(); report.Failures.Should().Contain(failure => failure.Code == PortableLibraryVerificationErrorCodes.MediaHashMismatch && failure.Entity == "media"); report.MediaFilesVerified.Should().Be(_fixture.Expected.Media.Count - 1); } [Fact] public async Task Missing_candidate_media_file_is_detected() { var report = await VerifyAsync(candidate => { File.Delete(MediaPath(candidate, 0)); return Task.CompletedTask; }); report.Passed.Should().BeFalse(); report.Failures.Should().Contain(failure => failure.Code == PortableLibraryVerificationErrorCodes.MediaMissing); } [Fact] public async Task Unexpected_candidate_media_file_is_detected() { var report = await VerifyAsync(candidate => { var folder = Path.Combine( candidate.Storage.StorageRoot, Guid.NewGuid().ToString()); Directory.CreateDirectory(folder); File.WriteAllText(Path.Combine(folder, "book.txt"), "stale media"); return Task.CompletedTask; }); report.Passed.Should().BeFalse(); report.Failures.Should().Contain(failure => failure.Code == PortableLibraryVerificationErrorCodes.MediaUnexpectedFile); } [Fact] public async Task Assistant_value_is_rejected_when_expected_absent() { await using var source = await LocalPortableTestLibrary.CreateAsync(); await PortableArchiveTestSupport.PopulateRepresentativeAsync(source.Db, source.Storage); source.Db.AssistantSettings.RemoveRange(source.Db.AssistantSettings); await source.Db.SaveChangesAsync(); var archive = await SelfHostedActivationTestSupport.ExportArchiveAsync(source); var store = new InMemoryPortableImportStagingStore(); await using var staging = new InMemoryPortableImportStaging(store); var prepared = await SelfHostedActivationTestSupport.PrepareStagedAsync(archive, staging); var expected = await _fixture.Verifier.VerifyPreparedImportAsync(staging, prepared); expected.Passed.Should().BeTrue(); expected.Media.Should().NotBeEmpty(); await using var candidate = await LocalPortableTestLibrary.CreateAsync(); using var stream = new MemoryStream(archive, writable: false); await candidate.Portability().ImportAsync(stream); var row = await candidate.Db.AssistantSettings.SingleOrDefaultAsync(); if (row is null) { row = new AssistantSettingsModel(); candidate.Db.AssistantSettings.Add(row); } row.CaptureProcessingMode = null; await candidate.Db.SaveChangesAsync(); var report = await _fixture.Verifier.VerifyCandidateAsync( candidate.Db, candidate.Storage.StorageRoot, prepared, expected); report.Passed.Should().BeFalse(); report.Failures.Should().Contain(failure => failure.Code == PortableLibraryVerificationErrorCodes.SingletonMismatch); } [Fact] public async Task Book_file_name_change_is_detected() { var report = await VerifyAsync(async candidate => { var book = await candidate.Db.Books.SingleAsync(item => item.Id == _fixture.EpubBookId); book.FileDetails.FileName = "book.pdf"; await candidate.Db.SaveChangesAsync(); }); ShouldFail(report, "book", nameof(FileInfoDetails.FileName)); } [Fact] public async Task Book_cover_file_name_change_is_detected() { var report = await VerifyAsync(async candidate => { var book = await candidate.Db.Books.SingleAsync(item => item.Id == _fixture.EpubBookId); book.FileDetails.CoverFileName = "cover.png"; await candidate.Db.SaveChangesAsync(); }); ShouldFail(report, "book", nameof(FileInfoDetails.CoverFileName)); } [Fact] public async Task Stale_cover_thumbnail_is_rejected() { var report = await VerifyAsync(candidate => { var cover = _fixture.Expected.Media.Single(descriptor => descriptor.Kind == "cover" && descriptor.BookId == _fixture.EpubBookId); var folder = Path.Combine(candidate.Storage.StorageRoot, cover.BookId.ToString()); Directory.CreateDirectory(folder); File.WriteAllText(Path.Combine(folder, "cover-thumb-320.webp"), "stale thumbnail"); return Task.CompletedTask; }); report.Passed.Should().BeFalse(); report.Failures.Should().Contain(failure => failure.Code == PortableLibraryVerificationErrorCodes.MediaUnexpectedFile); } [Fact] public async Task Assistant_updated_at_change_is_detected() { var report = await VerifyAsync(async candidate => { var row = await candidate.Db.AssistantSettings.SingleAsync(); row.UpdatedAtUtc = row.UpdatedAtUtc.AddDays(1); await candidate.Db.SaveChangesAsync(); }); ShouldFail(report, "assistantSettings", nameof(AssistantSettingsModel.UpdatedAtUtc)); } [Fact] public async Task Null_mode_singleton_baseline_passes() { var (candidate, prepared, expected) = await CreateNullModeScenarioAsync(); await using var _ = candidate; var report = await _fixture.Verifier.VerifyCandidateAsync( candidate.Db, candidate.Storage.StorageRoot, prepared, expected); report.Passed.Should().BeTrue( string.Join("; ", report.Failures.Select(failure => $"{failure.Code}:{failure.Field}"))); } [Fact] public async Task Null_mode_singleton_row_removal_is_detected() { var (candidate, prepared, expected) = await CreateNullModeScenarioAsync(); await using var _ = candidate; candidate.Db.AssistantSettings.RemoveRange(candidate.Db.AssistantSettings); await candidate.Db.SaveChangesAsync(); var report = await _fixture.Verifier.VerifyCandidateAsync( candidate.Db, candidate.Storage.StorageRoot, prepared, expected); report.Passed.Should().BeFalse(); report.Failures.Should().Contain(failure => failure.Code == PortableLibraryVerificationErrorCodes.SingletonMismatch); } [Fact] public async Task Null_mode_singleton_timestamp_change_is_detected() { var (candidate, prepared, expected) = await CreateNullModeScenarioAsync(); await using var _ = candidate; var row = await candidate.Db.AssistantSettings.SingleAsync(); row.UpdatedAtUtc = row.UpdatedAtUtc.AddDays(1); await candidate.Db.SaveChangesAsync(); var report = await _fixture.Verifier.VerifyCandidateAsync( candidate.Db, candidate.Storage.StorageRoot, prepared, expected); ShouldFail(report, "assistantSettings", nameof(AssistantSettingsModel.UpdatedAtUtc)); } [Fact] public async Task Null_mode_singleton_mode_swap_is_detected() { var (candidate, prepared, expected) = await CreateNullModeScenarioAsync(); await using var _ = candidate; var row = await candidate.Db.AssistantSettings.SingleAsync(); row.CaptureProcessingMode = "verbatim"; await candidate.Db.SaveChangesAsync(); var report = await _fixture.Verifier.VerifyCandidateAsync( candidate.Db, candidate.Storage.StorageRoot, prepared, expected); ShouldFail(report, "assistantSettings", nameof(AssistantSettingsModel.CaptureProcessingMode)); } [Fact] public async Task Verification_handle_from_another_prepared_import_is_rejected() { await using var candidate = await _fixture.CreateCandidateAsync(); var store = new InMemoryPortableImportStagingStore(); await using var otherStaging = new InMemoryPortableImportStaging(store); var otherPrepared = await SelfHostedActivationTestSupport.PrepareStagedAsync( _fixture.ArchiveBytes, otherStaging); var report = await _fixture.Verifier.VerifyCandidateAsync( candidate.Db, candidate.Storage.StorageRoot, otherPrepared, _fixture.Expected); report.Passed.Should().BeFalse(); report.Failures.Should().Contain(failure => failure.Code == PortableLibraryVerificationErrorCodes.ExpectedStateMismatch); } [Fact] public async Task Divergent_candidate_failure_details_are_bounded() { var report = await VerifyAsync(async candidate => { for (var index = 0; index < 200; index++) { candidate.Db.Topics.Add(new TopicModel { Topic = $"unexpected-{index}" }); } await candidate.Db.SaveChangesAsync(); }); report.Passed.Should().BeFalse(); report.FailureCount.Should().BeGreaterThan(PortableLibraryVerifier.MaxRetainedFailures); report.Failures.Count.Should().BeLessThanOrEqualTo(PortableLibraryVerifier.MaxRetainedFailures); } [Fact] public async Task Candidate_media_is_served_by_live_storage_and_thumbnails_generate_on_demand() { await using var source = await LocalPortableTestLibrary.CreateAsync(); var now = DateTime.UtcNow.AddDays(-1); var work = new WorkModel { Id = Guid.NewGuid(), Title = "Real image", NormalizedTitle = "REAL IMAGE", NormalizedAuthor = string.Empty, CreatedAt = now, }; var book = new EBookModel { Id = Guid.NewGuid(), WorkId = work.Id, Work = work, Title = "Real image", CreatedAt = now, FileDetails = new FileInfoDetails { HasFile = true, FileName = "book.epub", CoverFileName = "cover.png", }, }; source.Db.Works.Add(work); source.Db.Books.Add(book); await source.Db.SaveChangesAsync(); using var png = new MemoryStream(Nostos.Backend.Tests.Support.TestImages.Create(".png", 4, 4, red: 10, green: 20, blue: 30)); await source.Storage.SaveBookCoverAsync(book.Id, png, "cover.png"); await source.Storage.SaveBookFileAsync( book.Id, new MemoryStream(Encoding.UTF8.GetBytes("EPUB-BYTES")), "book.epub"); var archive = await SelfHostedActivationTestSupport.ExportArchiveAsync(source); var store = new InMemoryPortableImportStagingStore(); await using var staging = new InMemoryPortableImportStaging(store); var prepared = await SelfHostedActivationTestSupport.PrepareStagedAsync(archive, staging); var verifier = new PortableLibraryVerifier(); var expected = await verifier.VerifyPreparedImportAsync(staging, prepared); expected.Passed.Should().BeTrue(); await using var candidate = await LocalPortableTestLibrary.CreateAsync(); using var stream = new MemoryStream(archive, writable: false); await candidate.Portability().ImportAsync(stream); var report = await verifier.VerifyCandidateAsync( candidate.Db, candidate.Storage.StorageRoot, prepared, expected); report.Passed.Should().BeTrue( string.Join("; ", report.Failures.Select(failure => $"{failure.Code}:{failure.Field}"))); foreach (var descriptor in expected.Media) { StoredAssetRead? opened = descriptor.Kind == "book" ? await candidate.Storage.OpenBookFileAsync(descriptor.BookId) : await candidate.Storage.OpenBookCoverAsync(descriptor.BookId); opened.Should().NotBeNull(); await using (opened!) { var payload = await SelfHostedActivationTestSupport.ReadAllAsync(opened.Content); payload.LongLength.Should().Be(descriptor.Length); PortableArchiveTestSupport.Sha256Hex(payload).Should().Be(descriptor.Sha256); } } var thumbnail = await candidate.Storage.GetBookCoverThumbnailInfoAsync(book.Id, 320); thumbnail.Should().NotBeNull(); File.Exists(Path.Combine( candidate.Storage.StorageRoot, book.Id.ToString(), "cover-thumb-320.webp")).Should().BeTrue(); await using var thumbnailRead = await candidate.Storage.OpenBookCoverThumbnailAsync(book.Id, 320); thumbnailRead.Should().NotBeNull(); thumbnailRead!.Info.Length.Should().BeGreaterThan(0); } private static async Task<( LocalPortableTestLibrary Candidate, PortablePreparedImport Prepared, PortablePreparedImportVerification Expected)> CreateNullModeScenarioAsync() { await using var source = await LocalPortableTestLibrary.CreateAsync(); await PortableArchiveTestSupport.PopulateRepresentativeAsync(source.Db, source.Storage); var assistant = await source.Db.AssistantSettings.SingleAsync(); assistant.CaptureProcessingMode = null; assistant.UpdatedAtUtc = new DateTime(2026, 9, 1, 12, 0, 0, DateTimeKind.Utc); await source.Db.SaveChangesAsync(); var archive = await SelfHostedActivationTestSupport.ExportArchiveAsync(source); PortablePreparedImport prepared; PortablePreparedImportVerification expected; var store = new InMemoryPortableImportStagingStore(); await using (var staging = new InMemoryPortableImportStaging(store)) { prepared = await SelfHostedActivationTestSupport.PrepareStagedAsync(archive, staging); expected = await new PortableLibraryVerifier().VerifyPreparedImportAsync(staging, prepared); expected.Passed.Should().BeTrue( string.Join("; ", expected.Failures.Select(failure => failure.Code))); } var candidate = await LocalPortableTestLibrary.CreateAsync(); using var stream = new MemoryStream(archive, writable: false); await candidate.Portability().ImportAsync(stream); return (candidate, prepared, expected); } private async Task