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:
JannisandClaude Sonnet 5.5 committed 2026-10-10 14:28:09 +02:00
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);
}
}