fix: prevent potential component state updates after disposal and implement dedicated repository for quiz results
This commit is contained in:
@@ -23,6 +23,11 @@ public interface IEbookRepository
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
void AddEbook(Ebook ebook);
|
void AddEbook(Ebook ebook);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finds an ebook by its unique identifier.
|
||||||
|
/// </summary>
|
||||||
|
Task<Ebook?> FindByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Persists all staged changes to the underlying store.
|
/// Persists all staged changes to the underlying store.
|
||||||
/// </summary>
|
/// </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 FluentResults;
|
||||||
|
using System.Linq;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using NexusReader.Application.Abstractions.Messaging;
|
using NexusReader.Application.Abstractions.Messaging;
|
||||||
using NexusReader.Application.Abstractions.Persistence;
|
using NexusReader.Application.Abstractions.Persistence;
|
||||||
using NexusReader.Application.Abstractions.Services;
|
using NexusReader.Application.Abstractions.Services;
|
||||||
@@ -79,15 +81,37 @@ public class IngestEbookCommandHandler : IRequestHandler<IngestEbookCommand, Res
|
|||||||
// 4. Trigger asynchronous background processing and vector indexing
|
// 4. Trigger asynchronous background processing and vector indexing
|
||||||
_ = Task.Run(async () =>
|
_ = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var logger = scope.ServiceProvider.GetRequiredService<ILogger<IngestEbookCommandHandler>>();
|
||||||
|
var broadcaster = scope.ServiceProvider.GetRequiredService<ISyncBroadcaster>();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var scope = _scopeFactory.CreateScope();
|
|
||||||
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
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.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using NexusReader.Application.Abstractions.Messaging;
|
using NexusReader.Application.Abstractions.Messaging;
|
||||||
|
using NexusReader.Application.Abstractions.Persistence;
|
||||||
using NexusReader.Application.Abstractions.Services;
|
using NexusReader.Application.Abstractions.Services;
|
||||||
using NexusReader.Data.Persistence;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.Commands.Library;
|
namespace NexusReader.Application.Commands.Library;
|
||||||
|
|
||||||
@@ -18,20 +18,20 @@ public record ProcessEbookCommand(
|
|||||||
|
|
||||||
public class ProcessEbookCommandHandler : IRequestHandler<ProcessEbookCommand, Result<bool>>
|
public class ProcessEbookCommandHandler : IRequestHandler<ProcessEbookCommand, Result<bool>>
|
||||||
{
|
{
|
||||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
private readonly IEbookRepository _ebookRepository;
|
||||||
private readonly IKnowledgeService _knowledgeService;
|
private readonly IKnowledgeService _knowledgeService;
|
||||||
private readonly IEpubExtractor _epubExtractor;
|
private readonly IEpubExtractor _epubExtractor;
|
||||||
private readonly ISyncBroadcaster _broadcaster;
|
private readonly ISyncBroadcaster _broadcaster;
|
||||||
private readonly ILogger<ProcessEbookCommandHandler> _logger;
|
private readonly ILogger<ProcessEbookCommandHandler> _logger;
|
||||||
|
|
||||||
public ProcessEbookCommandHandler(
|
public ProcessEbookCommandHandler(
|
||||||
IDbContextFactory<AppDbContext> dbContextFactory,
|
IEbookRepository ebookRepository,
|
||||||
IKnowledgeService knowledgeService,
|
IKnowledgeService knowledgeService,
|
||||||
IEpubExtractor epubExtractor,
|
IEpubExtractor epubExtractor,
|
||||||
ISyncBroadcaster broadcaster,
|
ISyncBroadcaster broadcaster,
|
||||||
ILogger<ProcessEbookCommandHandler> logger)
|
ILogger<ProcessEbookCommandHandler> logger)
|
||||||
{
|
{
|
||||||
_dbContextFactory = dbContextFactory;
|
_ebookRepository = ebookRepository;
|
||||||
_knowledgeService = knowledgeService;
|
_knowledgeService = knowledgeService;
|
||||||
_epubExtractor = epubExtractor;
|
_epubExtractor = epubExtractor;
|
||||||
_broadcaster = broadcaster;
|
_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);
|
await _broadcaster.BroadcastIngestionProgressAsync(request.UserId, "Wyszukiwanie e-booka w bazie danych...", 0.05, cancellationToken);
|
||||||
|
|
||||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
var ebook = await _ebookRepository.FindByIdAsync(request.EbookId, cancellationToken);
|
||||||
var ebook = await dbContext.Ebooks.FindAsync(new object[] { request.EbookId }, cancellationToken);
|
|
||||||
if (ebook == null)
|
if (ebook == null)
|
||||||
{
|
{
|
||||||
_logger.LogError("[ProcessEbook] Ebook not found in database: {EbookId}", request.EbookId);
|
_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
|
// Mark the ebook as ready
|
||||||
ebook.IsReadyForReading = true;
|
ebook.IsReadyForReading = true;
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
await _ebookRepository.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
_logger.LogInformation("[ProcessEbook] Ingestion and vector indexing completed for: {Title}", ebook.Title);
|
_logger.LogInformation("[ProcessEbook] Ingestion and vector indexing completed for: {Title}", ebook.Title);
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,22 @@
|
|||||||
using FluentResults;
|
using FluentResults;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using NexusReader.Application.Abstractions.Messaging;
|
using NexusReader.Application.Abstractions.Messaging;
|
||||||
using NexusReader.Data.Persistence;
|
using NexusReader.Application.Abstractions.Persistence;
|
||||||
using NexusReader.Domain.Entities;
|
using NexusReader.Domain.Entities;
|
||||||
|
|
||||||
namespace NexusReader.Application.Commands.Quiz;
|
namespace NexusReader.Application.Commands.Quiz;
|
||||||
|
|
||||||
public sealed class SubmitQuizResultCommandHandler : ICommandHandler<SubmitQuizResultCommand>
|
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)
|
public async Task<Result> Handle(SubmitQuizResultCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
var user = await _quizResultRepository.FindUserByIdAsync(request.UserId, cancellationToken);
|
||||||
|
|
||||||
var user = await context.Users.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
|
|
||||||
if (user == null)
|
if (user == null)
|
||||||
{
|
{
|
||||||
return Result.Fail("User not found.");
|
return Result.Fail("User not found.");
|
||||||
@@ -36,8 +33,8 @@ public sealed class SubmitQuizResultCommandHandler : ICommandHandler<SubmitQuizR
|
|||||||
CompletedDate = DateTime.UtcNow
|
CompletedDate = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
|
|
||||||
context.QuizResults.Add(quizResult);
|
_quizResultRepository.AddQuizResult(quizResult);
|
||||||
await context.SaveChangesAsync(cancellationToken);
|
await _quizResultRepository.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
return Result.Ok();
|
return Result.Ok();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,14 +18,15 @@ public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, R
|
|||||||
public async Task<Result<UserProfileDto>> Handle(GetUserProfileQuery request, CancellationToken cancellationToken)
|
public async Task<Result<UserProfileDto>> Handle(GetUserProfileQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
var profile = await dbContext.Users
|
|
||||||
|
var userRaw = await dbContext.Users
|
||||||
.Where(u => u.Id == request.UserId)
|
.Where(u => u.Id == request.UserId)
|
||||||
.Select(u => new UserProfileDto
|
.Select(u => new
|
||||||
{
|
{
|
||||||
Email = u.Email ?? string.Empty,
|
Email = u.Email ?? string.Empty,
|
||||||
UserId = u.Id,
|
UserId = u.Id,
|
||||||
AITokensUsed = u.AITokensUsed,
|
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
|
Plan = u.SubscriptionPlan != null ? new SubscriptionPlanDto
|
||||||
{
|
{
|
||||||
Id = u.SubscriptionPlan.Id,
|
Id = u.SubscriptionPlan.Id,
|
||||||
@@ -33,12 +34,17 @@ public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, R
|
|||||||
AITokenLimit = u.SubscriptionPlan.AITokenLimit,
|
AITokenLimit = u.SubscriptionPlan.AITokenLimit,
|
||||||
MonthlyPrice = u.SubscriptionPlan.MonthlyPrice
|
MonthlyPrice = u.SubscriptionPlan.MonthlyPrice
|
||||||
} : new SubscriptionPlanDto(),
|
} : new SubscriptionPlanDto(),
|
||||||
AverageQuizScore = u.QuizResults.Any(q => q.TotalQuestions > 0)
|
QuizResults = u.QuizResults.Select(q => new
|
||||||
? (int)u.QuizResults.Where(q => q.TotalQuestions > 0).Average(q => (double)q.Score / q.TotalQuestions * 100)
|
{
|
||||||
: 0,
|
q.Score,
|
||||||
|
q.TotalQuestions,
|
||||||
|
q.Id,
|
||||||
|
q.Topic,
|
||||||
|
q.Percentage,
|
||||||
|
q.CompletedDate
|
||||||
|
}).ToList(),
|
||||||
DisplayName = u.DisplayName,
|
DisplayName = u.DisplayName,
|
||||||
BooksReadCount = u.Ebooks.Count(),
|
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
|
LastReadBook = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => new LastReadBookDto
|
||||||
{
|
{
|
||||||
Id = e.Id,
|
Id = e.Id,
|
||||||
@@ -55,26 +61,6 @@ public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, R
|
|||||||
Description = e.Description,
|
Description = e.Description,
|
||||||
IsReadyForReading = e.IsReadyForReading
|
IsReadyForReading = e.IsReadyForReading
|
||||||
}).FirstOrDefault(),
|
}).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
|
Roles = dbContext.UserRoles
|
||||||
.Where(ur => ur.UserId == u.Id)
|
.Where(ur => ur.UserId == u.Id)
|
||||||
.Join(dbContext.Roles, ur => ur.RoleId, r => r.Id, (ur, r) => r.Name!)
|
.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);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
if (profile == null)
|
if (userRaw == null)
|
||||||
{
|
{
|
||||||
return Result.Fail("Profile not found.");
|
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);
|
return Result.Ok(profile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ public static class DependencyInjection
|
|||||||
|
|
||||||
// Fix #1: Ebook repository (scoped, matches AppDbContext lifetime)
|
// Fix #1: Ebook repository (scoped, matches AppDbContext lifetime)
|
||||||
services.AddScoped<IEbookRepository, EbookRepository>();
|
services.AddScoped<IEbookRepository, EbookRepository>();
|
||||||
|
services.AddScoped<IQuizResultRepository, QuizResultRepository>();
|
||||||
|
|
||||||
// Fix #2: SignalR broadcaster (scoped, wraps IHubContext which is itself a singleton wrapper)
|
// Fix #2: SignalR broadcaster (scoped, wraps IHubContext which is itself a singleton wrapper)
|
||||||
services.AddScoped<ISyncBroadcaster, SignalRSyncBroadcaster>();
|
services.AddScoped<ISyncBroadcaster, SignalRSyncBroadcaster>();
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ internal sealed class EbookRepository : IEbookRepository
|
|||||||
_context.Ebooks.Add(ebook);
|
_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 />
|
/// <inheritdoc />
|
||||||
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||||
=> _context.SaveChangesAsync(cancellationToken);
|
=> _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 readonly IDriver _neo4jDriver;
|
||||||
private const string PromptVersion = "1.7";
|
private const string PromptVersion = "1.7";
|
||||||
private static readonly ConcurrentDictionary<string, Lazy<Task<Result<KnowledgePacket>>>> _activeRequests = new();
|
private static readonly ConcurrentDictionary<string, Lazy<Task<Result<KnowledgePacket>>>> _activeRequests = new();
|
||||||
|
private static readonly SemaphoreSlim _collectionSemaphore = new(1, 1);
|
||||||
|
|
||||||
public KnowledgeService(
|
public KnowledgeService(
|
||||||
IChatClient chatClient,
|
IChatClient chatClient,
|
||||||
@@ -454,6 +455,7 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
|
|
||||||
private async Task EnsureCollectionExistsAsync(string collectionName, CancellationToken cancellationToken = default)
|
private async Task EnsureCollectionExistsAsync(string collectionName, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
await _collectionSemaphore.WaitAsync(cancellationToken);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var exists = await _qdrantClient.CollectionExistsAsync(collectionName, cancellationToken);
|
var exists = await _qdrantClient.CollectionExistsAsync(collectionName, cancellationToken);
|
||||||
@@ -474,7 +476,19 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
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();
|
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>();
|
searchResult = new List<Qdrant.Client.Grpc.ScoredPoint>();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -594,7 +609,10 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
summary = sumObj?.ToString();
|
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}";
|
var text = string.IsNullOrEmpty(summary) ? content : $"{content}: {summary}";
|
||||||
return new RelevantContext
|
return new RelevantContext
|
||||||
@@ -747,7 +765,10 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
{
|
{
|
||||||
metadata = JsonSerializer.Deserialize<Dictionary<string, object>>(metaVal.StringValue);
|
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
|
var dto = new SemanticSearchResultDto
|
||||||
@@ -871,6 +892,8 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
{
|
{
|
||||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
var units = await dbContext.KnowledgeUnits
|
var units = await dbContext.KnowledgeUnits
|
||||||
|
.Include(u => u.Ebook)
|
||||||
|
.ThenInclude(e => e.Author)
|
||||||
.Where(u => u.TenantId == tenantId && (ebookId == null || u.EbookId == ebookId))
|
.Where(u => u.TenantId == tenantId && (ebookId == null || u.EbookId == ebookId))
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
guidMap = units.ToDictionary(u => GetDeterministicGuid(u.Id).ToString(), u => u);
|
guidMap = units.ToDictionary(u => GetDeterministicGuid(u.Id).ToString(), u => u);
|
||||||
@@ -916,7 +939,10 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
summary = sumObj?.ToString();
|
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}";
|
sourceText = string.IsNullOrEmpty(summary) ? sourceUnit.Content : $"{sourceUnit.Content}: {summary}";
|
||||||
}
|
}
|
||||||
@@ -954,7 +980,10 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
summary = sumObj?.ToString();
|
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}";
|
targetText = string.IsNullOrEmpty(summary) ? targetUnit.Content : $"{targetUnit.Content}: {summary}";
|
||||||
}
|
}
|
||||||
@@ -986,7 +1015,10 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
summary = sumObj?.ToString();
|
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}";
|
sourceText = string.IsNullOrEmpty(summary) ? sourceUnit.Content : $"{sourceUnit.Content}: {summary}";
|
||||||
}
|
}
|
||||||
@@ -1082,19 +1114,6 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
{
|
{
|
||||||
citation.Author = unit.Ebook.Author.Name;
|
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))
|
if (!string.IsNullOrEmpty(unit.MetadataJson))
|
||||||
{
|
{
|
||||||
@@ -1106,7 +1125,10 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
citation.PageNumber = pageVal;
|
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
|
@namespace NexusReader.UI.Shared.Components.Atoms
|
||||||
@using System.Text.RegularExpressions
|
@using System.Text.RegularExpressions
|
||||||
|
@using MediatR
|
||||||
@using NexusReader.Application.DTOs.AI
|
@using NexusReader.Application.DTOs.AI
|
||||||
@inject IKnowledgeService KnowledgeService
|
@using NexusReader.Application.Queries.Library
|
||||||
|
@inject IMediator Mediator
|
||||||
@inject IReaderNavigationService NavService
|
@inject IReaderNavigationService NavService
|
||||||
@inject IReaderInteractionService InteractionService
|
@inject IReaderInteractionService InteractionService
|
||||||
@inject NavigationManager NavManager
|
@inject NavigationManager NavManager
|
||||||
@@ -100,6 +102,7 @@
|
|||||||
private bool _isLoading;
|
private bool _isLoading;
|
||||||
private string? _searchError;
|
private string? _searchError;
|
||||||
private bool _isDropdownOpen;
|
private bool _isDropdownOpen;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
private CancellationTokenSource? _searchCts;
|
private CancellationTokenSource? _searchCts;
|
||||||
|
|
||||||
@@ -140,15 +143,18 @@
|
|||||||
{
|
{
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
_searchError = null;
|
_searchError = null;
|
||||||
await InvokeAsync(StateHasChanged);
|
if (!_disposed)
|
||||||
|
{
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||||
var tenantId = authState.User.FindFirst("TenantId")?.Value ?? "global";
|
var tenantId = authState.User.FindFirst("TenantId")?.Value ?? "global";
|
||||||
|
|
||||||
var result = await KnowledgeService.SearchLibrarySemanticallyAsync(SearchValue, tenantId, Limit, token);
|
var result = await Mediator.Send(new SearchLibrarySemanticallyQuery(SearchValue, tenantId, Limit), token);
|
||||||
if (token.IsCancellationRequested) return;
|
if (token.IsCancellationRequested || _disposed) return;
|
||||||
|
|
||||||
if (result.IsSuccess)
|
if (result.IsSuccess)
|
||||||
{
|
{
|
||||||
@@ -164,7 +170,7 @@
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
if (!token.IsCancellationRequested)
|
if (!token.IsCancellationRequested && !_disposed)
|
||||||
{
|
{
|
||||||
_results.Clear();
|
_results.Clear();
|
||||||
_searchError = "Wystąpił nieoczekiwany błąd podczas wyszukiwania.";
|
_searchError = "Wystąpił nieoczekiwany błąd podczas wyszukiwania.";
|
||||||
@@ -173,7 +179,7 @@
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
if (!token.IsCancellationRequested)
|
if (!token.IsCancellationRequested && !_disposed)
|
||||||
{
|
{
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
await InvokeAsync(StateHasChanged);
|
await InvokeAsync(StateHasChanged);
|
||||||
@@ -291,6 +297,7 @@
|
|||||||
IsFocused = false;
|
IsFocused = false;
|
||||||
// Delay slightly to allow click handlers on result cards to execute
|
// Delay slightly to allow click handlers on result cards to execute
|
||||||
await Task.Delay(200);
|
await Task.Delay(200);
|
||||||
|
if (_disposed) return;
|
||||||
_isDropdownOpen = false;
|
_isDropdownOpen = false;
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
@@ -305,29 +312,35 @@
|
|||||||
|
|
||||||
private string HighlightQueryWords(string text, string query)
|
private string HighlightQueryWords(string text, string query)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(text) || string.IsNullOrWhiteSpace(query))
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
return text;
|
return string.Empty;
|
||||||
|
|
||||||
|
var escapedText = System.Net.WebUtility.HtmlEncode(text);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(query))
|
||||||
|
return escapedText;
|
||||||
|
|
||||||
var words = query.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)
|
var words = query.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)
|
||||||
.Where(w => w.Length > 2)
|
.Where(w => w.Length > 2)
|
||||||
.Select(Regex.Escape);
|
.Select(Regex.Escape);
|
||||||
|
|
||||||
if (!words.Any())
|
if (!words.Any())
|
||||||
return text;
|
return escapedText;
|
||||||
|
|
||||||
var pattern = "(" + string.Join("|", words) + ")";
|
var pattern = "(" + string.Join("|", words) + ")";
|
||||||
try
|
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
|
catch
|
||||||
{
|
{
|
||||||
return text;
|
return escapedText;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
_disposed = true;
|
||||||
_searchCts?.Cancel();
|
_searchCts?.Cancel();
|
||||||
_searchCts?.Dispose();
|
_searchCts?.Dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,6 +142,7 @@
|
|||||||
private LocalEpubMetadata? Metadata { get; set; }
|
private LocalEpubMetadata? Metadata { get; set; }
|
||||||
private string? ErrorMessage { get; set; }
|
private string? ErrorMessage { get; set; }
|
||||||
private byte[]? _epubBytes;
|
private byte[]? _epubBytes;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
// Allow up to 50 MB
|
// Allow up to 50 MB
|
||||||
private const long MaxFileSize = 50 * 1024 * 1024;
|
private const long MaxFileSize = 50 * 1024 * 1024;
|
||||||
@@ -154,23 +155,30 @@
|
|||||||
|
|
||||||
private async Task HandleIngestionProgress(string message, double progress)
|
private async Task HandleIngestionProgress(string message, double progress)
|
||||||
{
|
{
|
||||||
|
if (_disposed) return;
|
||||||
if (!IsIndexing) return;
|
if (!IsIndexing) return;
|
||||||
|
|
||||||
IngestionStatusMessage = message;
|
IngestionStatusMessage = message;
|
||||||
IngestionProgressPercent = progress;
|
IngestionProgressPercent = progress;
|
||||||
|
|
||||||
await InvokeAsync(StateHasChanged);
|
if (!_disposed)
|
||||||
|
{
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
if (progress >= 1.0)
|
if (progress >= 1.0)
|
||||||
{
|
{
|
||||||
// Give the user a moment to see the completion message
|
// Give the user a moment to see the completion message
|
||||||
await Task.Delay(2500);
|
await Task.Delay(2500);
|
||||||
|
|
||||||
|
if (_disposed) return;
|
||||||
|
|
||||||
// Now close the modal and navigate to the book
|
// Now close the modal and navigate to the book
|
||||||
if (IngestedBookId != Guid.Empty)
|
if (IngestedBookId != Guid.Empty)
|
||||||
{
|
{
|
||||||
var bookId = IngestedBookId;
|
var bookId = IngestedBookId;
|
||||||
await InvokeAsync(async () => {
|
await InvokeAsync(async () => {
|
||||||
|
if (_disposed) return;
|
||||||
await CloseModal();
|
await CloseModal();
|
||||||
ReaderNavigation.NavigateToBook(bookId);
|
ReaderNavigation.NavigateToBook(bookId);
|
||||||
});
|
});
|
||||||
@@ -227,10 +235,12 @@
|
|||||||
using var stream = file.OpenReadStream(MaxFileSize);
|
using var stream = file.OpenReadStream(MaxFileSize);
|
||||||
using var memoryStream = new MemoryStream();
|
using var memoryStream = new MemoryStream();
|
||||||
await stream.CopyToAsync(memoryStream);
|
await stream.CopyToAsync(memoryStream);
|
||||||
|
if (_disposed) return;
|
||||||
_epubBytes = memoryStream.ToArray();
|
_epubBytes = memoryStream.ToArray();
|
||||||
|
|
||||||
memoryStream.Position = 0;
|
memoryStream.Position = 0;
|
||||||
var result = await MetadataExtractor.ExtractMetadataAsync(memoryStream);
|
var result = await MetadataExtractor.ExtractMetadataAsync(memoryStream);
|
||||||
|
if (_disposed) return;
|
||||||
|
|
||||||
if (result.IsSuccess)
|
if (result.IsSuccess)
|
||||||
{
|
{
|
||||||
@@ -245,12 +255,18 @@
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Logger.LogError(ex, "Error uploading EPUB");
|
Logger.LogError(ex, "Error uploading EPUB");
|
||||||
ErrorMessage = $"An unexpected error occurred: {ex.Message}";
|
if (!_disposed)
|
||||||
|
{
|
||||||
|
ErrorMessage = $"An unexpected error occurred: {ex.Message}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
IsParsing = false;
|
if (!_disposed)
|
||||||
StateHasChanged();
|
{
|
||||||
|
IsParsing = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,10 +289,12 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
var response = await Http.PostAsJsonAsync("api/library/ingest", request);
|
var response = await Http.PostAsJsonAsync("api/library/ingest", request);
|
||||||
|
if (_disposed) return;
|
||||||
|
|
||||||
if (response.IsSuccessStatusCode)
|
if (response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
var result = await response.Content.ReadFromJsonAsync<IngestResult>();
|
var result = await response.Content.ReadFromJsonAsync<IngestResult>();
|
||||||
|
if (_disposed) return;
|
||||||
if (result != null)
|
if (result != null)
|
||||||
{
|
{
|
||||||
IngestedBookId = result.Id;
|
IngestedBookId = result.Id;
|
||||||
@@ -297,12 +315,18 @@
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Logger.LogError(ex, "Error during ingestion");
|
Logger.LogError(ex, "Error during ingestion");
|
||||||
ErrorMessage = "Failed to save book to library. Please try again.";
|
if (!_disposed)
|
||||||
IsIngesting = false;
|
{
|
||||||
|
ErrorMessage = "Failed to save book to library. Please try again.";
|
||||||
|
IsIngesting = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
StateHasChanged();
|
if (!_disposed)
|
||||||
|
{
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,6 +334,7 @@
|
|||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
|
_disposed = true;
|
||||||
SyncService.OnIngestionProgressReceived -= HandleIngestionProgress;
|
SyncService.OnIngestionProgressReceived -= HandleIngestionProgress;
|
||||||
// Clear the large byte array so it is eligible for GC even if the component is cached.
|
// Clear the large byte array so it is eligible for GC even if the component is cached.
|
||||||
_epubBytes = null;
|
_epubBytes = null;
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ builder.Services.AddSingleton<IDbContextFactory<AppDbContext>>(new ThrowingDbCon
|
|||||||
builder.Services.AddSingleton<IEmbeddingGenerator<string, Embedding<float>>>(new ThrowingEmbeddingGenerator());
|
builder.Services.AddSingleton<IEmbeddingGenerator<string, Embedding<float>>>(new ThrowingEmbeddingGenerator());
|
||||||
builder.Services.AddSingleton<IBookStorageService>(new ThrowingBookStorageService());
|
builder.Services.AddSingleton<IBookStorageService>(new ThrowingBookStorageService());
|
||||||
builder.Services.AddSingleton<IEbookRepository>(new ThrowingEbookRepository());
|
builder.Services.AddSingleton<IEbookRepository>(new ThrowingEbookRepository());
|
||||||
|
builder.Services.AddSingleton<IQuizResultRepository>(new ThrowingQuizResultRepository());
|
||||||
builder.Services.AddSingleton<ISyncBroadcaster>(new ThrowingSyncBroadcaster());
|
builder.Services.AddSingleton<ISyncBroadcaster>(new ThrowingSyncBroadcaster());
|
||||||
builder.Services.AddSingleton<IEpubExtractor>(new ThrowingEpubExtractor());
|
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 Task<Author?> FindAuthorByNameAsync(string name, CancellationToken cancellationToken = default) => throw new NotSupportedException(ErrorMessage);
|
||||||
public void AddAuthor(Author author) => throw new NotSupportedException(ErrorMessage);
|
public void AddAuthor(Author author) => throw new NotSupportedException(ErrorMessage);
|
||||||
public void AddEbook(Ebook ebook) => 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);
|
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(ErrorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+36
-44
@@ -5,53 +5,46 @@ using FluentAssertions;
|
|||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Moq;
|
using Moq;
|
||||||
|
using NexusReader.Application.Abstractions.Persistence;
|
||||||
using NexusReader.Application.Commands.Quiz;
|
using NexusReader.Application.Commands.Quiz;
|
||||||
using NexusReader.Data.Persistence;
|
using NexusReader.Data.Persistence;
|
||||||
using NexusReader.Domain.Entities;
|
using NexusReader.Domain.Entities;
|
||||||
|
using NexusReader.Infrastructure.Persistence;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace NexusReader.Application.Tests.Commands;
|
namespace NexusReader.Application.Tests.Commands;
|
||||||
|
|
||||||
public class SubmitQuizResultCommandHandlerTests : IDisposable
|
public class SubmitQuizResultCommandHandlerTests
|
||||||
{
|
{
|
||||||
private readonly SqliteConnection _connection;
|
private readonly Mock<IQuizResultRepository> _repositoryMock;
|
||||||
private readonly DbContextOptions<AppDbContext> _contextOptions;
|
|
||||||
private readonly Mock<IDbContextFactory<AppDbContext>> _dbContextFactoryMock;
|
|
||||||
|
|
||||||
public SubmitQuizResultCommandHandlerTests()
|
public SubmitQuizResultCommandHandlerTests()
|
||||||
{
|
{
|
||||||
_connection = new SqliteConnection("DataSource=:memory:");
|
_repositoryMock = new Mock<IQuizResultRepository>();
|
||||||
_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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_WithValidRequest_PersistsQuizResultToDatabase()
|
public async Task Handle_WithValidRequest_PersistsQuizResultToDatabase()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using (var context = new AppDbContext(_contextOptions))
|
var user = new NexusUser
|
||||||
{
|
{
|
||||||
var user = new NexusUser
|
Id = "user-abc",
|
||||||
{
|
UserName = "testuser",
|
||||||
Id = "user-abc",
|
Email = "test@example.com",
|
||||||
UserName = "testuser",
|
TenantId = "tenant-xyz",
|
||||||
Email = "test@example.com",
|
SubscriptionPlanId = 1
|
||||||
TenantId = "tenant-xyz",
|
};
|
||||||
SubscriptionPlanId = 1
|
|
||||||
};
|
_repositoryMock.Setup(r => r.FindUserByIdAsync("user-abc", It.IsAny<CancellationToken>()))
|
||||||
context.Users.Add(user);
|
.ReturnsAsync(user);
|
||||||
await context.SaveChangesAsync();
|
|
||||||
}
|
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(
|
var command = new SubmitQuizResultCommand(
|
||||||
UserId: "user-abc",
|
UserId: "user-abc",
|
||||||
@@ -60,29 +53,30 @@ public class SubmitQuizResultCommandHandlerTests : IDisposable
|
|||||||
TotalQuestions: 5
|
TotalQuestions: 5
|
||||||
);
|
);
|
||||||
|
|
||||||
var handler = new SubmitQuizResultCommandHandler(_dbContextFactoryMock.Object);
|
var handler = new SubmitQuizResultCommandHandler(_repositoryMock.Object);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await handler.Handle(command, CancellationToken.None);
|
var result = await handler.Handle(command, CancellationToken.None);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
result.IsSuccess.Should().BeTrue();
|
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))
|
_repositoryMock.Verify(r => r.AddQuizResult(It.IsAny<QuizResult>()), Times.Once);
|
||||||
{
|
_repositoryMock.Verify(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_WithNonExistentUser_ReturnsFailureResult()
|
public async Task Handle_WithNonExistentUser_ReturnsFailureResult()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
_repositoryMock.Setup(r => r.FindUserByIdAsync("non-existent", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((NexusUser?)null);
|
||||||
|
|
||||||
var command = new SubmitQuizResultCommand(
|
var command = new SubmitQuizResultCommand(
|
||||||
UserId: "non-existent",
|
UserId: "non-existent",
|
||||||
Topic: "Sprawdzian: .NET 10",
|
Topic: "Sprawdzian: .NET 10",
|
||||||
@@ -90,7 +84,7 @@ public class SubmitQuizResultCommandHandlerTests : IDisposable
|
|||||||
TotalQuestions: 5
|
TotalQuestions: 5
|
||||||
);
|
);
|
||||||
|
|
||||||
var handler = new SubmitQuizResultCommandHandler(_dbContextFactoryMock.Object);
|
var handler = new SubmitQuizResultCommandHandler(_repositoryMock.Object);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await handler.Handle(command, CancellationToken.None);
|
var result = await handler.Handle(command, CancellationToken.None);
|
||||||
@@ -98,10 +92,8 @@ public class SubmitQuizResultCommandHandlerTests : IDisposable
|
|||||||
// Assert
|
// Assert
|
||||||
result.IsFailed.Should().BeTrue();
|
result.IsFailed.Should().BeTrue();
|
||||||
result.Errors.Should().ContainSingle(e => e.Message == "User not found.");
|
result.Errors.Should().ContainSingle(e => e.Message == "User not found.");
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
_repositoryMock.Verify(r => r.AddQuizResult(It.IsAny<QuizResult>()), Times.Never);
|
||||||
{
|
_repositoryMock.Verify(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Never);
|
||||||
_connection.Dispose();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user