fix: skip deleted sheets when auto-submitting, log rejected writes, test the background sweeper
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8fb34ba6b9
commit
cb48c542af
4 files changed
+64
-11
No files matched your search
@@ -11,12 +11,20 @@ namespace FamilyPlanner.Api.Features.Learning;
|
||||
/// Gemeinsame Abläufe der Versuche: Abgabe (auch automatisch nach Zeitablauf), Zeitablauf prüfen und Punkte im Punktebuch abgleichen.
|
||||
/// Wird von den Endpunkten und vom Hintergrunddienst genutzt, damit die Regeln nur einmal existieren.
|
||||
/// </summary>
|
||||
public sealed class LearningAttemptService(AppDbContext db, ISettingsProvider settingsProvider, LearningNotifier notifier, TimeProvider timeProvider)
|
||||
public sealed class LearningAttemptService(
|
||||
AppDbContext db, ISettingsProvider settingsProvider, LearningNotifier notifier, TimeProvider timeProvider, ILogger<LearningAttemptService> logger)
|
||||
{
|
||||
private const int MaxFinalizeBatch = 50;
|
||||
|
||||
public DateTimeOffset Now => timeProvider.GetUtcNow();
|
||||
|
||||
/// <summary>
|
||||
/// Protokolliert einen abgelehnten Schreibzugriff (gleichzeitige Änderung oder Verstoß gegen einen eindeutigen Index). Solche Fälle sind erwartbar, sollen aber
|
||||
/// sichtbar bleiben – eine falsch behandelte Entität sähe von außen genauso aus wie ein Konflikt.
|
||||
/// </summary>
|
||||
public void LogConflict(Exception exception, string operation) =>
|
||||
logger.LogWarning(exception, "Lernaufgaben: {Operation} wurde wegen eines Konflikts abgelehnt.", operation);
|
||||
|
||||
/// <summary>Alle Antworten fertig bewerten (leere = falsch; Elternbewertungen bleiben) und den Versuch als abgegeben markieren.</summary>
|
||||
public void Submit(LearningAttempt attempt, LearningSet set, DateTimeOffset now)
|
||||
{
|
||||
@@ -68,7 +76,8 @@ public sealed class LearningAttemptService(AppDbContext db, ISettingsProvider se
|
||||
var now = Now;
|
||||
var cutoff = now - LearningRules.DeadlineTolerance;
|
||||
var query = db.LearningAttempts
|
||||
.Where(a => a.Status == LearningAttemptStatus.InProgress && a.DeadlineAt != null && a.DeadlineAt < cutoff);
|
||||
.Where(a => a.Status == LearningAttemptStatus.InProgress && a.DeadlineAt != null && a.DeadlineAt < cutoff)
|
||||
.Where(a => db.LearningSets.Any(set => set.Id == a.SetId)); // gelöschte Blätter (Soft Delete) würden die Stapel dauerhaft blockieren
|
||||
if (userId is { } id)
|
||||
{
|
||||
query = query.Where(a => a.UserId == id);
|
||||
@@ -117,8 +126,9 @@ public sealed class LearningAttemptService(AppDbContext db, ISettingsProvider se
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
catch (DbUpdateException ex)
|
||||
{
|
||||
LogConflict(ex, "automatische Abgabe");
|
||||
// Gleichzeitig vom Kind abgegeben oder neue Antwort gespeichert: Zustand neu laden und erneut bewerten.
|
||||
db.ChangeTracker.Clear();
|
||||
continue;
|
||||
|
||||
@@ -391,7 +391,7 @@ internal static class LearningManageEndpoints
|
||||
}
|
||||
|
||||
private static async Task<IResult> ReviewAnswerAsync(
|
||||
Guid attemptId, Guid itemId, ReviewAnswerRequest request, AppDbContext db, TimeProvider time, CancellationToken ct)
|
||||
Guid attemptId, Guid itemId, ReviewAnswerRequest request, AppDbContext db, LearningAttemptService service, TimeProvider time, CancellationToken ct)
|
||||
{
|
||||
var loaded = await LoadAttemptAsync(db, attemptId, tracking: true, ct);
|
||||
if (loaded is null || loaded.Set.Items.SingleOrDefault(i => i.Id == itemId) is not { } item)
|
||||
@@ -432,8 +432,9 @@ internal static class LearningManageEndpoints
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
catch (DbUpdateException ex)
|
||||
{
|
||||
service.LogConflict(ex, "Bewertung speichern");
|
||||
return EndpointHelpers.Conflict("Der Versuch wurde inzwischen geändert. Bitte neu laden.");
|
||||
}
|
||||
|
||||
@@ -492,8 +493,9 @@ internal static class LearningManageEndpoints
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
catch (DbUpdateException ex)
|
||||
{
|
||||
service.LogConflict(ex, "Bestätigen");
|
||||
return EndpointHelpers.Conflict("Der Versuch wurde inzwischen geändert. Bitte neu laden.");
|
||||
}
|
||||
|
||||
@@ -542,8 +544,9 @@ internal static class LearningManageEndpoints
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
catch (DbUpdateException ex)
|
||||
{
|
||||
service.LogConflict(ex, "Erneut öffnen");
|
||||
return EndpointHelpers.Conflict("Der Versuch wurde inzwischen geändert. Bitte neu laden.");
|
||||
}
|
||||
|
||||
|
||||
@@ -92,7 +92,6 @@ internal static class LearningSolveEndpoints
|
||||
ISettingsProvider settingsProvider,
|
||||
LearningAttemptService service,
|
||||
LearningNotifier notifier,
|
||||
ILogger<LearningAttemptService> logger,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var actor = await user.LearningActorAsync(settingsProvider);
|
||||
@@ -140,7 +139,7 @@ internal static class LearningSolveEndpoints
|
||||
}
|
||||
catch (DbUpdateException ex)
|
||||
{
|
||||
logger.LogInformation(ex, "Versuch konnte nicht gespeichert werden, neuer Durchlauf.");
|
||||
service.LogConflict(ex, "Versuch starten");
|
||||
// Doppelklick/zweiter Tab: der andere Start hat gewonnen (eindeutiger Index) – beim nächsten Durchlauf wird er fortgesetzt.
|
||||
continue;
|
||||
}
|
||||
@@ -240,8 +239,9 @@ internal static class LearningSolveEndpoints
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
catch (DbUpdateException ex)
|
||||
{
|
||||
service.LogConflict(ex, "Antwort speichern");
|
||||
continue; // Abgabe oder zweiter Tab gleichzeitig – Zustand neu lesen
|
||||
}
|
||||
}
|
||||
@@ -288,8 +288,9 @@ internal static class LearningSolveEndpoints
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
catch (DbUpdateException ex)
|
||||
{
|
||||
service.LogConflict(ex, "Abgabe");
|
||||
continue; // gleichzeitig gespeichert/abgegeben
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using FamilyPlanner.Domain;
|
||||
using FamilyPlanner.Domain.Learning;
|
||||
using FamilyPlanner.Shared.Dashboard;
|
||||
using FamilyPlanner.Shared.Learning;
|
||||
using static FamilyPlanner.IntegrationTests.LearningSupport;
|
||||
|
||||
@@ -92,4 +94,41 @@ public sealed class LearningTimeLimitTests(AppFixture fixture)
|
||||
Assert.Equal(2, next.Number);
|
||||
Assert.Equal(LearningAttemptStatus.Submitted, (await GetAttemptAsync(kid, attempt.Id)).Status);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Beweist den Hintergrunddienst <c>LearningExpiryService</c>: Nach dem Start macht das Kind <b>keinen</b> Lern-Aufruf mehr (jeder davon würde den
|
||||
/// abgelaufenen Versuch lazy abgeben). Beobachtet wird nur die Dashboard-Kachel des Kindes, die selbst nichts abgibt – ihre Zahl offener
|
||||
/// Blätter sinkt nur, wenn der Dienst den Versuch tatsächlich abgegeben hat. Ein frisches Kind macht den Zähler privat (parallele Tests stören nicht).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task The_background_service_submits_expired_attempts_without_any_learning_request()
|
||||
{
|
||||
var (child, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
|
||||
var (_, parent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
|
||||
var set = await CreateAsync(parent, NewSet(child.Id) with { Mode = LearningMode.Exam, TimeLimitMinutes = 1, MaxAttempts = 1 });
|
||||
var attempt = await StartAsync(kid, set.Set.Id);
|
||||
await SaveAsync(kid, attempt.Id, attempt.Items[0].ItemId, "4711,5");
|
||||
|
||||
var tile = (await kid.GetFromJsonAsync<DashboardDto>("/api/dashboard", Ct))!.Learning!;
|
||||
Assert.Equal(1, tile.OpenCount); // läuft noch
|
||||
|
||||
// Ab hier nur noch das Dashboard des Kindes abfragen – kein /api/learning-Aufruf, der lazy abgeben könnte.
|
||||
var deadline = attempt.DeadlineAt!.Value + LearningRules.DeadlineTolerance;
|
||||
var give = DateTimeOffset.UtcNow + TimeSpan.FromMinutes(3);
|
||||
var open = 1;
|
||||
while (open != 0 && DateTimeOffset.UtcNow < give)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(3), Ct);
|
||||
open = (await kid.GetFromJsonAsync<DashboardDto>("/api/dashboard", Ct))!.Learning!.OpenCount;
|
||||
}
|
||||
|
||||
Assert.True(open == 0, "Der Hintergrunddienst hat den abgelaufenen Versuch nicht abgegeben.");
|
||||
Assert.True(DateTimeOffset.UtcNow > deadline, "Der Versuch darf erst nach Frist plus Toleranz abgegeben werden.");
|
||||
|
||||
// Ergebnis prüfen (jetzt dürfen Lern-Aufrufe kommen): automatisch abgegeben, rechtzeitig gespeicherte Antwort zählt
|
||||
var submitted = await GetAttemptAsync(kid, attempt.Id);
|
||||
Assert.Equal((LearningAttemptStatus.Submitted, true), (submitted.Status, submitted.AutoSubmitted));
|
||||
Assert.Equal(attempt.DeadlineAt, submitted.Result!.SubmittedAt);
|
||||
Assert.Equal(2, submitted.Result.EarnedPoints);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user