diff --git a/src/NexusReader.Application/Abstractions/Persistence/IEbookRepository.cs b/src/NexusReader.Application/Abstractions/Persistence/IEbookRepository.cs index 021d0d5..c0a560a 100644 --- a/src/NexusReader.Application/Abstractions/Persistence/IEbookRepository.cs +++ b/src/NexusReader.Application/Abstractions/Persistence/IEbookRepository.cs @@ -23,6 +23,11 @@ public interface IEbookRepository /// void AddEbook(Ebook ebook); + /// + /// Finds an ebook by its unique identifier. + /// + Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default); + /// /// Persists all staged changes to the underlying store. /// diff --git a/src/NexusReader.Application/Abstractions/Persistence/IQuizResultRepository.cs b/src/NexusReader.Application/Abstractions/Persistence/IQuizResultRepository.cs new file mode 100644 index 0000000..87b2cd7 --- /dev/null +++ b/src/NexusReader.Application/Abstractions/Persistence/IQuizResultRepository.cs @@ -0,0 +1,25 @@ +using NexusReader.Domain.Entities; + +namespace NexusReader.Application.Abstractions.Persistence; + +/// +/// Abstraction for QuizResult and related User entity lookup. +/// Defined in the Application layer to maintain Clean Architecture isolation. +/// +public interface IQuizResultRepository +{ + /// + /// Finds a user by ID to extract tenant context. + /// + Task FindUserByIdAsync(string userId, CancellationToken cancellationToken = default); + + /// + /// Adds a new quiz result to the database. + /// + void AddQuizResult(QuizResult quizResult); + + /// + /// Persists all staged changes to the repository. + /// + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/NexusReader.Application/Commands/Library/IngestEbookCommandHandler.cs b/src/NexusReader.Application/Commands/Library/IngestEbookCommandHandler.cs index ca53adc..e611d4b 100644 --- a/src/NexusReader.Application/Commands/Library/IngestEbookCommandHandler.cs +++ b/src/NexusReader.Application/Commands/Library/IngestEbookCommandHandler.cs @@ -1,6 +1,8 @@ using FluentResults; +using System.Linq; using MediatR; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using NexusReader.Application.Abstractions.Messaging; using NexusReader.Application.Abstractions.Persistence; using NexusReader.Application.Abstractions.Services; @@ -79,15 +81,37 @@ public class IngestEbookCommandHandler : IRequestHandler { + using var scope = _scopeFactory.CreateScope(); + var logger = scope.ServiceProvider.GetRequiredService>(); + var broadcaster = scope.ServiceProvider.GetRequiredService(); try { - using var scope = _scopeFactory.CreateScope(); var mediator = scope.ServiceProvider.GetRequiredService(); - await mediator.Send(new ProcessEbookCommand(ebook.Id, request.UserId, request.TenantId)); + var result = await mediator.Send(new ProcessEbookCommand(ebook.Id, request.UserId, request.TenantId)); + if (result.IsFailed) + { + var errorMsg = string.Join("; ", result.Errors.Select(e => e.Message)); + logger.LogError("[IngestEbook] Background ebook processing failed for Ebook {EbookId}: {Error}", ebook.Id, errorMsg); + await broadcaster.BroadcastIngestionProgressAsync( + request.UserId, + $"Błąd indeksowania: {errorMsg}", + 1.0); + } } - catch (Exception) + catch (Exception ex) { - // Swallowed to prevent ThreadPool crashes + logger.LogError(ex, "[IngestEbook] Exception during background ebook processing for Ebook {EbookId}", ebook.Id); + try + { + await broadcaster.BroadcastIngestionProgressAsync( + request.UserId, + $"Błąd krytyczny podczas przetwarzania e-booka: {ex.Message}", + 1.0); + } + catch + { + // Ignore broadcast failures to prevent crashes + } } }); diff --git a/src/NexusReader.Application/Commands/Library/ProcessEbookCommand.cs b/src/NexusReader.Application/Commands/Library/ProcessEbookCommand.cs index 5a1e9c4..a7f6698 100644 --- a/src/NexusReader.Application/Commands/Library/ProcessEbookCommand.cs +++ b/src/NexusReader.Application/Commands/Library/ProcessEbookCommand.cs @@ -5,8 +5,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using NexusReader.Application.Abstractions.Messaging; +using NexusReader.Application.Abstractions.Persistence; using NexusReader.Application.Abstractions.Services; -using NexusReader.Data.Persistence; namespace NexusReader.Application.Commands.Library; @@ -18,20 +18,20 @@ public record ProcessEbookCommand( public class ProcessEbookCommandHandler : IRequestHandler> { - private readonly IDbContextFactory _dbContextFactory; + private readonly IEbookRepository _ebookRepository; private readonly IKnowledgeService _knowledgeService; private readonly IEpubExtractor _epubExtractor; private readonly ISyncBroadcaster _broadcaster; private readonly ILogger _logger; public ProcessEbookCommandHandler( - IDbContextFactory dbContextFactory, + IEbookRepository ebookRepository, IKnowledgeService knowledgeService, IEpubExtractor epubExtractor, ISyncBroadcaster broadcaster, ILogger logger) { - _dbContextFactory = dbContextFactory; + _ebookRepository = ebookRepository; _knowledgeService = knowledgeService; _epubExtractor = epubExtractor; _broadcaster = broadcaster; @@ -46,8 +46,7 @@ public class ProcessEbookCommandHandler : IRequestHandler { - private readonly IDbContextFactory _dbContextFactory; + private readonly IQuizResultRepository _quizResultRepository; - public SubmitQuizResultCommandHandler(IDbContextFactory dbContextFactory) + public SubmitQuizResultCommandHandler(IQuizResultRepository quizResultRepository) { - _dbContextFactory = dbContextFactory; + _quizResultRepository = quizResultRepository; } public async Task Handle(SubmitQuizResultCommand request, CancellationToken cancellationToken) { - using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - - var user = await context.Users.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken); + var user = await _quizResultRepository.FindUserByIdAsync(request.UserId, cancellationToken); if (user == null) { return Result.Fail("User not found."); @@ -36,8 +33,8 @@ public sealed class SubmitQuizResultCommandHandler : ICommandHandler> Handle(GetUserProfileQuery request, CancellationToken cancellationToken) { using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - var profile = await dbContext.Users + + var userRaw = await dbContext.Users .Where(u => u.Id == request.UserId) - .Select(u => new UserProfileDto + .Select(u => new { Email = u.Email ?? string.Empty, UserId = u.Id, AITokensUsed = u.AITokensUsed, - TenantId = u.TenantId != null && u.TenantId.Length == 36 ? new Guid(u.TenantId) : Guid.Empty, + TenantIdString = u.TenantId, Plan = u.SubscriptionPlan != null ? new SubscriptionPlanDto { Id = u.SubscriptionPlan.Id, @@ -33,12 +34,17 @@ public class GetUserProfileQueryHandler : IRequestHandler q.TotalQuestions > 0) - ? (int)u.QuizResults.Where(q => q.TotalQuestions > 0).Average(q => (double)q.Score / q.TotalQuestions * 100) - : 0, + QuizResults = u.QuizResults.Select(q => new + { + q.Score, + q.TotalQuestions, + q.Id, + q.Topic, + q.Percentage, + q.CompletedDate + }).ToList(), DisplayName = u.DisplayName, BooksReadCount = u.Ebooks.Count(), - ConceptsMappedCount = dbContext.KnowledgeUnits.Count(k => k.TenantId == u.TenantId || k.TenantId == "global" || string.IsNullOrEmpty(k.TenantId)), LastReadBook = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => new LastReadBookDto { Id = e.Id, @@ -55,26 +61,6 @@ public class GetUserProfileQueryHandler : IRequestHandler q.CompletedDate).Take(5).Select(q => new QuizResultDto - { - Id = q.Id, - Topic = q.Topic, - Score = q.Score, - TotalQuestions = q.TotalQuestions, - Percentage = q.Percentage, - CompletedDate = q.CompletedDate - }).ToList(), - MappedConcepts = dbContext.KnowledgeUnits - .Where(k => k.TenantId == u.TenantId || k.TenantId == "global" || string.IsNullOrEmpty(k.TenantId)) - .OrderByDescending(k => k.CreatedAt) - .Take(6) - .Select(k => new MappedConceptDto - { - Id = k.Id, - Type = k.Type.ToString(), - Content = k.Content - }) - .ToList(), Roles = dbContext.UserRoles .Where(ur => ur.UserId == u.Id) .Join(dbContext.Roles, ur => ur.RoleId, r => r.Id, (ur, r) => r.Name!) @@ -82,11 +68,59 @@ public class GetUserProfileQueryHandler : IRequestHandler k.TenantId == tenantId || k.TenantId == "global" || string.IsNullOrEmpty(k.TenantId)) + .OrderByDescending(k => k.CreatedAt) + .Take(6) + .Select(k => new MappedConceptDto + { + Id = k.Id, + Type = k.Type.ToString(), + Content = k.Content + }) + .ToListAsync(cancellationToken); + + var conceptsMappedCount = await dbContext.KnowledgeUnits + .CountAsync(k => k.TenantId == tenantId || k.TenantId == "global" || string.IsNullOrEmpty(k.TenantId), cancellationToken); + + int averageQuizScore = 0; + var validQuizzes = userRaw.QuizResults.Where(q => q.TotalQuestions > 0).ToList(); + if (validQuizzes.Count > 0) + { + averageQuizScore = (int)(validQuizzes.Average(q => (double)q.Score / q.TotalQuestions) * 100); + } + + var profile = new UserProfileDto + { + Email = userRaw.Email, + UserId = userRaw.UserId, + AITokensUsed = userRaw.AITokensUsed, + TenantId = userRaw.TenantIdString != null && userRaw.TenantIdString.Length == 36 ? new Guid(userRaw.TenantIdString) : Guid.Empty, + Plan = userRaw.Plan, + AverageQuizScore = averageQuizScore, + DisplayName = userRaw.DisplayName, + BooksReadCount = userRaw.BooksReadCount, + ConceptsMappedCount = conceptsMappedCount, + LastReadBook = userRaw.LastReadBook, + RecentQuizzes = userRaw.QuizResults.OrderByDescending(q => q.CompletedDate).Take(5).Select(q => new QuizResultDto + { + Id = q.Id, + Topic = q.Topic, + Score = q.Score, + TotalQuestions = q.TotalQuestions, + Percentage = q.Percentage, + CompletedDate = q.CompletedDate + }).ToList(), + MappedConcepts = mappedConcepts, + Roles = userRaw.Roles + }; + return Result.Ok(profile); } } diff --git a/src/NexusReader.Infrastructure/DependencyInjection.cs b/src/NexusReader.Infrastructure/DependencyInjection.cs index 6bc61ab..58ea65f 100644 --- a/src/NexusReader.Infrastructure/DependencyInjection.cs +++ b/src/NexusReader.Infrastructure/DependencyInjection.cs @@ -120,6 +120,7 @@ public static class DependencyInjection // Fix #1: Ebook repository (scoped, matches AppDbContext lifetime) services.AddScoped(); + services.AddScoped(); // Fix #2: SignalR broadcaster (scoped, wraps IHubContext which is itself a singleton wrapper) services.AddScoped(); diff --git a/src/NexusReader.Infrastructure/Persistence/EbookRepository.cs b/src/NexusReader.Infrastructure/Persistence/EbookRepository.cs index 5e23e09..f6d964c 100644 --- a/src/NexusReader.Infrastructure/Persistence/EbookRepository.cs +++ b/src/NexusReader.Infrastructure/Persistence/EbookRepository.cs @@ -46,6 +46,12 @@ internal sealed class EbookRepository : IEbookRepository _context.Ebooks.Add(ebook); } + /// + public async Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.Ebooks.FindAsync(new object[] { id }, cancellationToken); + } + /// public Task SaveChangesAsync(CancellationToken cancellationToken = default) => _context.SaveChangesAsync(cancellationToken); diff --git a/src/NexusReader.Infrastructure/Persistence/QuizResultRepository.cs b/src/NexusReader.Infrastructure/Persistence/QuizResultRepository.cs new file mode 100644 index 0000000..3403bb2 --- /dev/null +++ b/src/NexusReader.Infrastructure/Persistence/QuizResultRepository.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore; +using NexusReader.Application.Abstractions.Persistence; +using NexusReader.Data.Persistence; +using NexusReader.Domain.Entities; + +namespace NexusReader.Infrastructure.Persistence; + +/// +/// EF Core implementation of . +/// +internal sealed class QuizResultRepository : IQuizResultRepository +{ + private readonly AppDbContext _context; + + public QuizResultRepository(AppDbContext context) + { + _context = context; + } + + /// + public async Task FindUserByIdAsync(string userId, CancellationToken cancellationToken = default) + { + return await _context.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken); + } + + /// + public void AddQuizResult(QuizResult quizResult) + { + _context.QuizResults.Add(quizResult); + } + + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return _context.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs index c52d40d..0c0097c 100644 --- a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs +++ b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs @@ -35,6 +35,7 @@ public class KnowledgeService : IKnowledgeService private readonly IDriver _neo4jDriver; private const string PromptVersion = "1.7"; private static readonly ConcurrentDictionary>>> _activeRequests = new(); + private static readonly SemaphoreSlim _collectionSemaphore = new(1, 1); public KnowledgeService( IChatClient chatClient, @@ -454,6 +455,7 @@ public class KnowledgeService : IKnowledgeService private async Task EnsureCollectionExistsAsync(string collectionName, CancellationToken cancellationToken = default) { + await _collectionSemaphore.WaitAsync(cancellationToken); try { var exists = await _qdrantClient.CollectionExistsAsync(collectionName, cancellationToken); @@ -474,7 +476,19 @@ public class KnowledgeService : IKnowledgeService } catch (Exception ex) { - _logger.LogError(ex, "[KnowledgeService] Error ensuring Qdrant collection '{CollectionName}' exists.", collectionName); + if (ex.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase) || + (ex.InnerException != null && ex.InnerException.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase))) + { + _logger.LogInformation("[KnowledgeService] Qdrant collection '{CollectionName}' was already created by another thread.", collectionName); + } + else + { + _logger.LogError(ex, "[KnowledgeService] Error ensuring Qdrant collection '{CollectionName}' exists.", collectionName); + } + } + finally + { + _collectionSemaphore.Release(); } } @@ -575,8 +589,9 @@ public class KnowledgeService : IKnowledgeService ); searchResult = response.ToList(); } - catch (Exception) + catch (Exception ex) { + _logger.LogWarning(ex, "[KnowledgeService] Qdrant search failed during GetRelevantContextAsync. Returning empty search results."); searchResult = new List(); } @@ -594,7 +609,10 @@ public class KnowledgeService : IKnowledgeService summary = sumObj?.ToString(); } } - catch {} + catch (JsonException ex) + { + _logger.LogWarning(ex, "[KnowledgeService] Failed to deserialize metadata JSON in RelevantContext mapping."); + } } var text = string.IsNullOrEmpty(summary) ? content : $"{content}: {summary}"; return new RelevantContext @@ -747,7 +765,10 @@ public class KnowledgeService : IKnowledgeService { metadata = JsonSerializer.Deserialize>(metaVal.StringValue); } - catch {} + catch (JsonException ex) + { + _logger.LogWarning(ex, "[KnowledgeService] Failed to deserialize metadata JSON in search library mapping."); + } } var dto = new SemanticSearchResultDto @@ -871,6 +892,8 @@ public class KnowledgeService : IKnowledgeService { using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); var units = await dbContext.KnowledgeUnits + .Include(u => u.Ebook) + .ThenInclude(e => e.Author) .Where(u => u.TenantId == tenantId && (ebookId == null || u.EbookId == ebookId)) .ToListAsync(cancellationToken); guidMap = units.ToDictionary(u => GetDeterministicGuid(u.Id).ToString(), u => u); @@ -916,7 +939,10 @@ public class KnowledgeService : IKnowledgeService summary = sumObj?.ToString(); } } - catch { } + catch (JsonException jsonEx) + { + _logger.LogWarning(jsonEx, "[KnowledgeService] Failed to deserialize metadata JSON for unit {UnitId} in AskQuestionAsync source hydration.", sourceUnit.Id); + } } sourceText = string.IsNullOrEmpty(summary) ? sourceUnit.Content : $"{sourceUnit.Content}: {summary}"; } @@ -954,7 +980,10 @@ public class KnowledgeService : IKnowledgeService summary = sumObj?.ToString(); } } - catch { } + catch (JsonException jsonEx) + { + _logger.LogWarning(jsonEx, "[KnowledgeService] Failed to deserialize metadata JSON for unit {UnitId} in AskQuestionAsync target hydration.", targetUnit.Id); + } } targetText = string.IsNullOrEmpty(summary) ? targetUnit.Content : $"{targetUnit.Content}: {summary}"; } @@ -986,7 +1015,10 @@ public class KnowledgeService : IKnowledgeService summary = sumObj?.ToString(); } } - catch { } + catch (JsonException jsonEx) + { + _logger.LogWarning(jsonEx, "[KnowledgeService] Failed to deserialize metadata JSON for unit {UnitId} in fallback AskQuestionAsync.", sourceUnit.Id); + } } sourceText = string.IsNullOrEmpty(summary) ? sourceUnit.Content : $"{sourceUnit.Content}: {summary}"; } @@ -1082,19 +1114,6 @@ public class KnowledgeService : IKnowledgeService { citation.Author = unit.Ebook.Author.Name; } - else if (unit.EbookId.HasValue) - { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - var eb = await dbContext.Ebooks.Include(e => e.Author).FirstOrDefaultAsync(e => e.Id == unit.EbookId.Value, cancellationToken); - if (eb?.Author != null) - { - citation.Author = eb.Author.Name; - } - } - catch { } - } if (!string.IsNullOrEmpty(unit.MetadataJson)) { @@ -1106,7 +1125,10 @@ public class KnowledgeService : IKnowledgeService citation.PageNumber = pageVal; } } - catch { } + catch (JsonException ex) + { + _logger.LogWarning(ex, "[KnowledgeService] Failed to deserialize metadata JSON for unit {UnitId} in AskQuestionAsync citation mapping.", unit.Id); + } } } } diff --git a/src/NexusReader.UI.Shared/Components/Atoms/NexusSearchBox.razor b/src/NexusReader.UI.Shared/Components/Atoms/NexusSearchBox.razor index bdfb4ae..cc81e71 100644 --- a/src/NexusReader.UI.Shared/Components/Atoms/NexusSearchBox.razor +++ b/src/NexusReader.UI.Shared/Components/Atoms/NexusSearchBox.razor @@ -1,7 +1,9 @@ @namespace NexusReader.UI.Shared.Components.Atoms @using System.Text.RegularExpressions +@using MediatR @using NexusReader.Application.DTOs.AI -@inject IKnowledgeService KnowledgeService +@using NexusReader.Application.Queries.Library +@inject IMediator Mediator @inject IReaderNavigationService NavService @inject IReaderInteractionService InteractionService @inject NavigationManager NavManager @@ -100,6 +102,7 @@ private bool _isLoading; private string? _searchError; private bool _isDropdownOpen; + private bool _disposed; private CancellationTokenSource? _searchCts; @@ -140,15 +143,18 @@ { _isLoading = true; _searchError = null; - await InvokeAsync(StateHasChanged); + if (!_disposed) + { + await InvokeAsync(StateHasChanged); + } try { var authState = await AuthStateProvider.GetAuthenticationStateAsync(); var tenantId = authState.User.FindFirst("TenantId")?.Value ?? "global"; - var result = await KnowledgeService.SearchLibrarySemanticallyAsync(SearchValue, tenantId, Limit, token); - if (token.IsCancellationRequested) return; + var result = await Mediator.Send(new SearchLibrarySemanticallyQuery(SearchValue, tenantId, Limit), token); + if (token.IsCancellationRequested || _disposed) return; if (result.IsSuccess) { @@ -164,7 +170,7 @@ } catch (Exception ex) { - if (!token.IsCancellationRequested) + if (!token.IsCancellationRequested && !_disposed) { _results.Clear(); _searchError = "Wystąpił nieoczekiwany błąd podczas wyszukiwania."; @@ -173,7 +179,7 @@ } finally { - if (!token.IsCancellationRequested) + if (!token.IsCancellationRequested && !_disposed) { _isLoading = false; await InvokeAsync(StateHasChanged); @@ -291,6 +297,7 @@ IsFocused = false; // Delay slightly to allow click handlers on result cards to execute await Task.Delay(200); + if (_disposed) return; _isDropdownOpen = false; StateHasChanged(); } @@ -305,29 +312,35 @@ private string HighlightQueryWords(string text, string query) { - if (string.IsNullOrWhiteSpace(text) || string.IsNullOrWhiteSpace(query)) - return text; + if (string.IsNullOrWhiteSpace(text)) + return string.Empty; + + var escapedText = System.Net.WebUtility.HtmlEncode(text); + + if (string.IsNullOrWhiteSpace(query)) + return escapedText; var words = query.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries) .Where(w => w.Length > 2) .Select(Regex.Escape); if (!words.Any()) - return text; + return escapedText; var pattern = "(" + string.Join("|", words) + ")"; try { - return Regex.Replace(text, pattern, "$1", RegexOptions.IgnoreCase); + return Regex.Replace(escapedText, pattern, "$1", RegexOptions.IgnoreCase); } catch { - return text; + return escapedText; } } public void Dispose() { + _disposed = true; _searchCts?.Cancel(); _searchCts?.Dispose(); } diff --git a/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor b/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor index 4af21f7..bb04130 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor +++ b/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor @@ -142,6 +142,7 @@ private LocalEpubMetadata? Metadata { get; set; } private string? ErrorMessage { get; set; } private byte[]? _epubBytes; + private bool _disposed; // Allow up to 50 MB private const long MaxFileSize = 50 * 1024 * 1024; @@ -154,23 +155,30 @@ private async Task HandleIngestionProgress(string message, double progress) { + if (_disposed) return; if (!IsIndexing) return; IngestionStatusMessage = message; IngestionProgressPercent = progress; - await InvokeAsync(StateHasChanged); + if (!_disposed) + { + await InvokeAsync(StateHasChanged); + } if (progress >= 1.0) { // Give the user a moment to see the completion message await Task.Delay(2500); + if (_disposed) return; + // Now close the modal and navigate to the book if (IngestedBookId != Guid.Empty) { var bookId = IngestedBookId; await InvokeAsync(async () => { + if (_disposed) return; await CloseModal(); ReaderNavigation.NavigateToBook(bookId); }); @@ -227,10 +235,12 @@ using var stream = file.OpenReadStream(MaxFileSize); using var memoryStream = new MemoryStream(); await stream.CopyToAsync(memoryStream); + if (_disposed) return; _epubBytes = memoryStream.ToArray(); memoryStream.Position = 0; var result = await MetadataExtractor.ExtractMetadataAsync(memoryStream); + if (_disposed) return; if (result.IsSuccess) { @@ -245,12 +255,18 @@ catch (Exception ex) { Logger.LogError(ex, "Error uploading EPUB"); - ErrorMessage = $"An unexpected error occurred: {ex.Message}"; + if (!_disposed) + { + ErrorMessage = $"An unexpected error occurred: {ex.Message}"; + } } finally { - IsParsing = false; - StateHasChanged(); + if (!_disposed) + { + IsParsing = false; + StateHasChanged(); + } } } @@ -273,10 +289,12 @@ ); var response = await Http.PostAsJsonAsync("api/library/ingest", request); + if (_disposed) return; if (response.IsSuccessStatusCode) { var result = await response.Content.ReadFromJsonAsync(); + if (_disposed) return; if (result != null) { IngestedBookId = result.Id; @@ -297,12 +315,18 @@ catch (Exception ex) { Logger.LogError(ex, "Error during ingestion"); - ErrorMessage = "Failed to save book to library. Please try again."; - IsIngesting = false; + if (!_disposed) + { + ErrorMessage = "Failed to save book to library. Please try again."; + IsIngesting = false; + } } finally { - StateHasChanged(); + if (!_disposed) + { + StateHasChanged(); + } } } @@ -310,6 +334,7 @@ public async ValueTask DisposeAsync() { + _disposed = true; SyncService.OnIngestionProgressReceived -= HandleIngestionProgress; // Clear the large byte array so it is eligible for GC even if the component is cached. _epubBytes = null; diff --git a/src/NexusReader.Web.Client/Program.cs b/src/NexusReader.Web.Client/Program.cs index bf431b7..9eed57e 100644 --- a/src/NexusReader.Web.Client/Program.cs +++ b/src/NexusReader.Web.Client/Program.cs @@ -50,6 +50,7 @@ builder.Services.AddSingleton>(new ThrowingDbCon builder.Services.AddSingleton>>(new ThrowingEmbeddingGenerator()); builder.Services.AddSingleton(new ThrowingBookStorageService()); builder.Services.AddSingleton(new ThrowingEbookRepository()); +builder.Services.AddSingleton(new ThrowingQuizResultRepository()); builder.Services.AddSingleton(new ThrowingSyncBroadcaster()); builder.Services.AddSingleton(new ThrowingEpubExtractor()); @@ -89,6 +90,16 @@ public class ThrowingEbookRepository : IEbookRepository public Task FindAuthorByNameAsync(string name, CancellationToken cancellationToken = default) => throw new NotSupportedException(ErrorMessage); public void AddAuthor(Author author) => throw new NotSupportedException(ErrorMessage); public void AddEbook(Ebook ebook) => throw new NotSupportedException(ErrorMessage); + public Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default) => throw new NotSupportedException(ErrorMessage); + public Task SaveChangesAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(ErrorMessage); +} + +public class ThrowingQuizResultRepository : IQuizResultRepository +{ + private const string ErrorMessage = "QuizResult repository operations are not supported in the WASM client. Use the API endpoint for data access."; + + public Task FindUserByIdAsync(string userId, CancellationToken cancellationToken = default) => throw new NotSupportedException(ErrorMessage); + public void AddQuizResult(QuizResult quizResult) => throw new NotSupportedException(ErrorMessage); public Task SaveChangesAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(ErrorMessage); } diff --git a/tests/NexusReader.Application.Tests/Commands/SubmitQuizResultCommandHandlerTests.cs b/tests/NexusReader.Application.Tests/Commands/SubmitQuizResultCommandHandlerTests.cs index 4c9902a..d683d46 100644 --- a/tests/NexusReader.Application.Tests/Commands/SubmitQuizResultCommandHandlerTests.cs +++ b/tests/NexusReader.Application.Tests/Commands/SubmitQuizResultCommandHandlerTests.cs @@ -5,53 +5,46 @@ using FluentAssertions; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Moq; +using NexusReader.Application.Abstractions.Persistence; using NexusReader.Application.Commands.Quiz; using NexusReader.Data.Persistence; using NexusReader.Domain.Entities; +using NexusReader.Infrastructure.Persistence; using Xunit; namespace NexusReader.Application.Tests.Commands; -public class SubmitQuizResultCommandHandlerTests : IDisposable +public class SubmitQuizResultCommandHandlerTests { - private readonly SqliteConnection _connection; - private readonly DbContextOptions _contextOptions; - private readonly Mock> _dbContextFactoryMock; + private readonly Mock _repositoryMock; public SubmitQuizResultCommandHandlerTests() { - _connection = new SqliteConnection("DataSource=:memory:"); - _connection.Open(); - - _contextOptions = new DbContextOptionsBuilder() - .UseSqlite(_connection) - .Options; - - using var context = new AppDbContext(_contextOptions); - context.Database.EnsureCreated(); - - _dbContextFactoryMock = new Mock>(); - _dbContextFactoryMock.Setup(f => f.CreateDbContextAsync(It.IsAny())) - .ReturnsAsync(() => new AppDbContext(_contextOptions)); + _repositoryMock = new Mock(); } [Fact] public async Task Handle_WithValidRequest_PersistsQuizResultToDatabase() { // Arrange - using (var context = new AppDbContext(_contextOptions)) + var user = new NexusUser { - var user = new NexusUser - { - Id = "user-abc", - UserName = "testuser", - Email = "test@example.com", - TenantId = "tenant-xyz", - SubscriptionPlanId = 1 - }; - context.Users.Add(user); - await context.SaveChangesAsync(); - } + Id = "user-abc", + UserName = "testuser", + Email = "test@example.com", + TenantId = "tenant-xyz", + SubscriptionPlanId = 1 + }; + + _repositoryMock.Setup(r => r.FindUserByIdAsync("user-abc", It.IsAny())) + .ReturnsAsync(user); + + QuizResult? capturedQuizResult = null; + _repositoryMock.Setup(r => r.AddQuizResult(It.IsAny())) + .Callback(q => capturedQuizResult = q); + + _repositoryMock.Setup(r => r.SaveChangesAsync(It.IsAny())) + .ReturnsAsync(1); var command = new SubmitQuizResultCommand( UserId: "user-abc", @@ -60,29 +53,30 @@ public class SubmitQuizResultCommandHandlerTests : IDisposable TotalQuestions: 5 ); - var handler = new SubmitQuizResultCommandHandler(_dbContextFactoryMock.Object); + var handler = new SubmitQuizResultCommandHandler(_repositoryMock.Object); // Act var result = await handler.Handle(command, CancellationToken.None); // Assert result.IsSuccess.Should().BeTrue(); + capturedQuizResult.Should().NotBeNull(); + capturedQuizResult!.Topic.Should().Be("Sprawdzian: .NET 10"); + capturedQuizResult.Score.Should().Be(4); + capturedQuizResult.TotalQuestions.Should().Be(5); + capturedQuizResult.TenantId.Should().Be("tenant-xyz"); - using (var context = new AppDbContext(_contextOptions)) - { - var quizResult = await context.QuizResults.FirstOrDefaultAsync(q => q.UserId == "user-abc"); - quizResult.Should().NotBeNull(); - quizResult!.Topic.Should().Be("Sprawdzian: .NET 10"); - quizResult.Score.Should().Be(4); - quizResult.TotalQuestions.Should().Be(5); - quizResult.TenantId.Should().Be("tenant-xyz"); - } + _repositoryMock.Verify(r => r.AddQuizResult(It.IsAny()), Times.Once); + _repositoryMock.Verify(r => r.SaveChangesAsync(It.IsAny()), Times.Once); } [Fact] public async Task Handle_WithNonExistentUser_ReturnsFailureResult() { // Arrange + _repositoryMock.Setup(r => r.FindUserByIdAsync("non-existent", It.IsAny())) + .ReturnsAsync((NexusUser?)null); + var command = new SubmitQuizResultCommand( UserId: "non-existent", Topic: "Sprawdzian: .NET 10", @@ -90,7 +84,7 @@ public class SubmitQuizResultCommandHandlerTests : IDisposable TotalQuestions: 5 ); - var handler = new SubmitQuizResultCommandHandler(_dbContextFactoryMock.Object); + var handler = new SubmitQuizResultCommandHandler(_repositoryMock.Object); // Act var result = await handler.Handle(command, CancellationToken.None); @@ -98,10 +92,8 @@ public class SubmitQuizResultCommandHandlerTests : IDisposable // Assert result.IsFailed.Should().BeTrue(); result.Errors.Should().ContainSingle(e => e.Message == "User not found."); - } - public void Dispose() - { - _connection.Dispose(); + _repositoryMock.Verify(r => r.AddQuizResult(It.IsAny()), Times.Never); + _repositoryMock.Verify(r => r.SaveChangesAsync(It.IsAny()), Times.Never); } }