feat(search/rag): implement NexusSearchBox, dynamic Qdrant collection auto-provisioning, batch vector ingestion, mobile Serilog logging, and resolve 401 auth handler error #51

Merged
mjasin merged 10 commits from feat/nexus-search-box into develop 2026-05-26 12:15:29 +00:00
14 changed files with 323 additions and 132 deletions
Showing only changes of commit a2aecf7dd3 - Show all commits
@@ -23,6 +23,11 @@ public interface IEbookRepository
/// </summary>
void AddEbook(Ebook ebook);
/// <summary>
/// Finds an ebook by its unique identifier.
/// </summary>
Task<Ebook?> FindByIdAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Persists all staged changes to the underlying store.
/// </summary>
@@ -0,0 +1,25 @@
using NexusReader.Domain.Entities;
namespace NexusReader.Application.Abstractions.Persistence;
/// <summary>
/// Abstraction for QuizResult and related User entity lookup.
/// Defined in the Application layer to maintain Clean Architecture isolation.
/// </summary>
public interface IQuizResultRepository
{
/// <summary>
/// Finds a user by ID to extract tenant context.
/// </summary>
Task<NexusUser?> FindUserByIdAsync(string userId, CancellationToken cancellationToken = default);
/// <summary>
/// Adds a new quiz result to the database.
/// </summary>
void AddQuizResult(QuizResult quizResult);
/// <summary>
/// Persists all staged changes to the repository.
/// </summary>
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
@@ -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<IngestEbookCommand, Res
// 4. Trigger asynchronous background processing and vector indexing
_ = Task.Run(async () =>
{
using var scope = _scopeFactory.CreateScope();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<IngestEbookCommandHandler>>();
var broadcaster = scope.ServiceProvider.GetRequiredService<ISyncBroadcaster>();
try
{
using var scope = _scopeFactory.CreateScope();
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
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
}
}
});
@@ -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<ProcessEbookCommand, Result<bool>>
{
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
private readonly IEbookRepository _ebookRepository;
private readonly IKnowledgeService _knowledgeService;
private readonly IEpubExtractor _epubExtractor;
private readonly ISyncBroadcaster _broadcaster;
private readonly ILogger<ProcessEbookCommandHandler> _logger;
public ProcessEbookCommandHandler(
IDbContextFactory<AppDbContext> dbContextFactory,
IEbookRepository ebookRepository,
IKnowledgeService knowledgeService,
IEpubExtractor epubExtractor,
ISyncBroadcaster broadcaster,
ILogger<ProcessEbookCommandHandler> logger)
{
_dbContextFactory = dbContextFactory;
_ebookRepository = ebookRepository;
_knowledgeService = knowledgeService;
_epubExtractor = epubExtractor;
_broadcaster = broadcaster;
@@ -46,8 +46,7 @@ public class ProcessEbookCommandHandler : IRequestHandler<ProcessEbookCommand, R
{
await _broadcaster.BroadcastIngestionProgressAsync(request.UserId, "Wyszukiwanie e-booka w bazie danych...", 0.05, cancellationToken);
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var ebook = await dbContext.Ebooks.FindAsync(new object[] { request.EbookId }, cancellationToken);
var ebook = await _ebookRepository.FindByIdAsync(request.EbookId, cancellationToken);
if (ebook == null)
{
_logger.LogError("[ProcessEbook] Ebook not found in database: {EbookId}", request.EbookId);
@@ -122,7 +121,7 @@ public class ProcessEbookCommandHandler : IRequestHandler<ProcessEbookCommand, R
// Mark the ebook as ready
ebook.IsReadyForReading = true;
await dbContext.SaveChangesAsync(cancellationToken);
await _ebookRepository.SaveChangesAsync(cancellationToken);
_logger.LogInformation("[ProcessEbook] Ingestion and vector indexing completed for: {Title}", ebook.Title);
@@ -1,25 +1,22 @@
using FluentResults;
using Microsoft.EntityFrameworkCore;
using NexusReader.Application.Abstractions.Messaging;
using NexusReader.Data.Persistence;
using NexusReader.Application.Abstractions.Persistence;
using NexusReader.Domain.Entities;
namespace NexusReader.Application.Commands.Quiz;
public sealed class SubmitQuizResultCommandHandler : ICommandHandler<SubmitQuizResultCommand>
{
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
private readonly IQuizResultRepository _quizResultRepository;
public SubmitQuizResultCommandHandler(IDbContextFactory<AppDbContext> dbContextFactory)
public SubmitQuizResultCommandHandler(IQuizResultRepository quizResultRepository)
{
_dbContextFactory = dbContextFactory;
_quizResultRepository = quizResultRepository;
}
public async Task<Result> 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<SubmitQuizR
CompletedDate = DateTime.UtcNow
};
context.QuizResults.Add(quizResult);
await context.SaveChangesAsync(cancellationToken);
_quizResultRepository.AddQuizResult(quizResult);
await _quizResultRepository.SaveChangesAsync(cancellationToken);
return Result.Ok();
}
@@ -18,14 +18,15 @@ public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, R
public async Task<Result<UserProfileDto>> 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<GetUserProfileQuery, R
AITokenLimit = u.SubscriptionPlan.AITokenLimit,
MonthlyPrice = u.SubscriptionPlan.MonthlyPrice
} : new SubscriptionPlanDto(),
AverageQuizScore = u.QuizResults.Any(q => 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<GetUserProfileQuery, R
Description = e.Description,
IsReadyForReading = e.IsReadyForReading
}).FirstOrDefault(),
RecentQuizzes = u.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 = 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<GetUserProfileQuery, R
})
.FirstOrDefaultAsync(cancellationToken);
if (profile == null)
if (userRaw == null)
{
return Result.Fail("Profile not found.");
}
var tenantId = userRaw.TenantIdString;
var mappedConcepts = await dbContext.KnowledgeUnits
.Where(k => 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);
}
}
@@ -120,6 +120,7 @@ public static class DependencyInjection
// Fix #1: Ebook repository (scoped, matches AppDbContext lifetime)
services.AddScoped<IEbookRepository, EbookRepository>();
services.AddScoped<IQuizResultRepository, QuizResultRepository>();
// Fix #2: SignalR broadcaster (scoped, wraps IHubContext which is itself a singleton wrapper)
services.AddScoped<ISyncBroadcaster, SignalRSyncBroadcaster>();
@@ -46,6 +46,12 @@ internal sealed class EbookRepository : IEbookRepository
_context.Ebooks.Add(ebook);
}
/// <inheritdoc />
public async Task<Ebook?> FindByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
return await _context.Ebooks.FindAsync(new object[] { id }, cancellationToken);
}
/// <inheritdoc />
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
=> _context.SaveChangesAsync(cancellationToken);
@@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore;
using NexusReader.Application.Abstractions.Persistence;
using NexusReader.Data.Persistence;
using NexusReader.Domain.Entities;
namespace NexusReader.Infrastructure.Persistence;
/// <summary>
/// EF Core implementation of <see cref="IQuizResultRepository"/>.
/// </summary>
internal sealed class QuizResultRepository : IQuizResultRepository
{
private readonly AppDbContext _context;
public QuizResultRepository(AppDbContext context)
{
_context = context;
}
/// <inheritdoc />
public async Task<NexusUser?> FindUserByIdAsync(string userId, CancellationToken cancellationToken = default)
{
return await _context.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
}
/// <inheritdoc />
public void AddQuizResult(QuizResult quizResult)
{
_context.QuizResults.Add(quizResult);
}
/// <inheritdoc />
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
return _context.SaveChangesAsync(cancellationToken);
}
}
@@ -35,6 +35,7 @@ public class KnowledgeService : IKnowledgeService
private readonly IDriver _neo4jDriver;
private const string PromptVersion = "1.7";
private static readonly ConcurrentDictionary<string, Lazy<Task<Result<KnowledgePacket>>>> _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<Qdrant.Client.Grpc.ScoredPoint>();
}
@@ -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<Dictionary<string, object>>(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);
}
}
}
}
@@ -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, "<mark class=\"search-highlight\">$1</mark>", RegexOptions.IgnoreCase);
return Regex.Replace(escapedText, pattern, "<mark class=\"search-highlight\">$1</mark>", RegexOptions.IgnoreCase);
}
catch
{
return text;
return escapedText;
}
}
public void Dispose()
{
_disposed = true;
_searchCts?.Cancel();
_searchCts?.Dispose();
}
@@ -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<IngestResult>();
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;
+11
View File
@@ -50,6 +50,7 @@ builder.Services.AddSingleton<IDbContextFactory<AppDbContext>>(new ThrowingDbCon
builder.Services.AddSingleton<IEmbeddingGenerator<string, Embedding<float>>>(new ThrowingEmbeddingGenerator());
builder.Services.AddSingleton<IBookStorageService>(new ThrowingBookStorageService());
builder.Services.AddSingleton<IEbookRepository>(new ThrowingEbookRepository());
builder.Services.AddSingleton<IQuizResultRepository>(new ThrowingQuizResultRepository());
builder.Services.AddSingleton<ISyncBroadcaster>(new ThrowingSyncBroadcaster());
builder.Services.AddSingleton<IEpubExtractor>(new ThrowingEpubExtractor());
@@ -89,6 +90,16 @@ public class ThrowingEbookRepository : IEbookRepository
public Task<Author?> 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<Ebook?> FindByIdAsync(Guid id, CancellationToken cancellationToken = default) => throw new NotSupportedException(ErrorMessage);
public Task<int> 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<NexusUser?> FindUserByIdAsync(string userId, CancellationToken cancellationToken = default) => throw new NotSupportedException(ErrorMessage);
public void AddQuizResult(QuizResult quizResult) => throw new NotSupportedException(ErrorMessage);
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(ErrorMessage);
}
@@ -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<AppDbContext> _contextOptions;
private readonly Mock<IDbContextFactory<AppDbContext>> _dbContextFactoryMock;
private readonly Mock<IQuizResultRepository> _repositoryMock;
public SubmitQuizResultCommandHandlerTests()
{
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
_contextOptions = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(_connection)
.Options;
using var context = new AppDbContext(_contextOptions);
context.Database.EnsureCreated();
_dbContextFactoryMock = new Mock<IDbContextFactory<AppDbContext>>();
_dbContextFactoryMock.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(() => new AppDbContext(_contextOptions));
_repositoryMock = new Mock<IQuizResultRepository>();
}
[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<CancellationToken>()))
.ReturnsAsync(user);
QuizResult? capturedQuizResult = null;
_repositoryMock.Setup(r => r.AddQuizResult(It.IsAny<QuizResult>()))
.Callback<QuizResult>(q => capturedQuizResult = q);
_repositoryMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
.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<QuizResult>()), Times.Once);
_repositoryMock.Verify(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Handle_WithNonExistentUser_ReturnsFailureResult()
{
// Arrange
_repositoryMock.Setup(r => r.FindUserByIdAsync("non-existent", It.IsAny<CancellationToken>()))
.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<QuizResult>()), Times.Never);
_repositoryMock.Verify(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Never);
}
}