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);
}
}