test: integration tests for learning schedules

This commit is contained in:
Jannis committed 2026-10-10 19:14:59 +02:00
1 parent dcbcac5ed8
commit 5a4e4c2e88
1 file changed
+363
@@ -0,0 +1,363 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Ai;
using FamilyPlanner.Domain.Common;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Shared;
using FamilyPlanner.Shared.Admin;
using FamilyPlanner.Shared.Ai;
using FamilyPlanner.Shared.Learning;
using static FamilyPlanner.IntegrationTests.LearningSupport;
namespace FamilyPlanner.IntegrationTests;
/// <summary>
/// Automatische Übungsblätter (Phase 11c) end-to-end: Rechte, Validierung, „Jetzt ausführen“ (Entwurf/zugewiesen, Qualitätsgrenze, Schwierigkeit, Tageslimit, Fehler),
/// Audit ohne Inhalt und ein echter Lauf des Hintergrunddiensts. Die Anbieter sind global – darum in der Settings-Collection. Zeitpläne, die kein Test absichtlich laufen lässt,
/// sind pausiert oder liegen in der Zukunft, damit der Hintergrunddienst nie einen fremden Fake-Anbieter anspricht; jeder Test räumt seine Zeitpläne weg.
/// </summary>
[Collection(SettingsCollection.Name)]
public sealed class LearningScheduleTests(AppFixture fixture)
{
private const string ExtraMarker = "WUNSCH-MARKER-9981";
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private static string OllamaEnvelope(string sheetJson) => JsonSerializer.Serialize(new { message = new { role = "assistant", content = sheetJson }, prompt_eval_count = 10, eval_count = 20 });
private const string GoodSheet = """
{"title":"Automatisch","items":[
{"question":"Was ergibt 9423 : 2?","type":"number","answer":"4711,5","expression":"9423 / 2","difficulty":3,"explanation":"Halbieren."},
{"question":"Was ergibt 7 + 8?","type":"number","answer":"15","expression":"7 + 8","difficulty":1,"hint":"Rechne erst 7 + 3."},
{"question":"Welche Zahl ist gerade?","type":"choice","answer":"12","options":["7","12","9"],"difficulty":2}
]}
""";
private static async Task ClearProvidersAsync(HttpClient admin)
{
foreach (var provider in (await admin.GetFromJsonAsync<List<AiProviderDto>>("/api/admin/ai/providers", Ct))!)
{
await admin.DeleteAsync($"/api/admin/ai/providers/{provider.Id}", Ct);
}
}
private static async Task ConfigureAsync(HttpClient admin, string baseUrl, int limit = 0)
{
await ClearProvidersAsync(admin);
var request = new SaveAiProviderRequest("Fake-Ollama Zeitplan", AiProviderType.Ollama, baseUrl, "fake-model", null, false, 30, true, false, AiPurpose.Default | AiPurpose.LearningGeneration, limit);
Assert.Equal(HttpStatusCode.Created, (await admin.PostAsJsonAsync("/api/admin/ai/providers", request, Ct)).StatusCode);
}
private static SaveLearningScheduleRequest Plan(Guid childId, int count = 3, bool autoAssign = false, int raiseEvery = 0, bool active = false, string? extra = null) =>
new(childId, LearningSubjects.Math, 3, count, 1, raiseEvery, extra, LearningWeekdays.All, new TimeOnly(15, 0), autoAssign, active);
private static async Task<LearningScheduleDto> CreateAsync(HttpClient parent, SaveLearningScheduleRequest request)
{
var response = await parent.PostAsJsonAsync("/api/learning/schedules", request, Ct);
Assert.True(response.StatusCode == HttpStatusCode.Created, await response.Content.ReadAsStringAsync(Ct));
return (await response.Content.ReadFromJsonAsync<LearningScheduleDto>(Ct))!;
}
private static Task<HttpResponseMessage> RunAsync(HttpClient parent, Guid id) => parent.PostAsync($"/api/learning/schedules/{id}/run", null, Ct);
private static async Task<LearningScheduleDto> GetAsync(HttpClient parent, Guid id) =>
(await parent.GetFromJsonAsync<List<LearningScheduleDto>>("/api/learning/schedules", Ct))!.Single(s => s.Id == id);
private static async Task DeleteAsync(HttpClient parent, Guid id) => await parent.DeleteAsync($"/api/learning/schedules/{id}", Ct);
private static string SystemPrompt(string requestBody)
{
using var json = JsonDocument.Parse(requestBody);
return json.RootElement.GetProperty("messages")[0].GetProperty("content").GetString()!;
}
// ---------- Rechte und Validierung ----------
[Fact]
public async Task Only_parents_with_ai_and_learning_rights_may_use_schedules_even_for_reading()
{
var (child, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (_, guest) = await fixture.CreateSignedInUserAsync(Roles.Guest, Ct);
var (parentUser, parent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
using var anonymous = fixture.CreateApiClient();
using var _k = kid;
using var _g = guest;
using var _p = parent;
var plan = await CreateAsync(parent, Plan(child.Id));
try
{
var body = Plan(child.Id);
foreach (var (client, expected) in new[] { (kid, HttpStatusCode.Forbidden), (guest, HttpStatusCode.Forbidden), (anonymous, HttpStatusCode.Unauthorized) })
{
Assert.Equal(expected, (await client.GetAsync("/api/learning/schedules", Ct)).StatusCode);
Assert.Equal(expected, (await client.PostAsJsonAsync("/api/learning/schedules", body, Ct)).StatusCode);
Assert.Equal(expected, (await client.PutAsJsonAsync($"/api/learning/schedules/{plan.Id}", body, Ct)).StatusCode);
Assert.Equal(expected, (await client.DeleteAsync($"/api/learning/schedules/{plan.Id}", Ct)).StatusCode);
Assert.Equal(expected, (await RunAsync(client, plan.Id)).StatusCode);
}
// Ziel muss ein Kind sein; ungültige Eingaben → 400
Assert.Equal(HttpStatusCode.BadRequest, (await parent.PostAsJsonAsync("/api/learning/schedules", Plan(parentUser.Id), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await parent.PostAsJsonAsync("/api/learning/schedules", Plan(Guid.NewGuid()), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await parent.PostAsJsonAsync("/api/learning/schedules", Plan(child.Id, count: 0), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await parent.PostAsJsonAsync("/api/learning/schedules", Plan(child.Id, count: 31), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await parent.PostAsJsonAsync("/api/learning/schedules", Plan(child.Id) with { Subject = "Sport" }, Ct)).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await parent.PostAsJsonAsync("/api/learning/schedules", Plan(child.Id) with { Weekdays = LearningWeekdays.None }, Ct)).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await parent.PostAsJsonAsync("/api/learning/schedules", Plan(child.Id) with { StartDifficulty = 4 }, Ct)).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await parent.PostAsJsonAsync("/api/learning/schedules", Plan(child.Id) with { RaiseEveryRuns = -1 }, Ct)).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await parent.PostAsJsonAsync("/api/learning/schedules", Plan(child.Id, extra: new string('a', 301)), Ct)).StatusCode);
}
finally
{
await DeleteAsync(parent, plan.Id);
}
}
[Fact]
public async Task Schedules_are_created_paused_or_active_without_firing_immediately_and_updates_check_the_version()
{
var (child, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (_, parent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
using var _k = kid;
using var _p = parent;
var created = await CreateAsync(parent, Plan(child.Id, active: false));
try
{
Assert.Equal((child.DisplayName, false, null as DateTimeOffset?, LearningScheduleStatus.Never, 0), (created.ChildName, created.IsActive, created.NextRunAt, created.LastStatus, created.RunCount));
// Aktivieren: nächster Termin liegt in der Zukunft; es läuft nichts sofort
var updated = await parent.PutAsJsonAsync($"/api/learning/schedules/{created.Id}", Plan(child.Id, active: true) with { Version = created.Version, Extra = " nur Plus " }, Ct);
Assert.Equal(HttpStatusCode.OK, updated.StatusCode);
var active = (await updated.Content.ReadFromJsonAsync<LearningScheduleDto>(Ct))!;
Assert.True(active.IsActive);
Assert.True(active.NextRunAt > DateTimeOffset.UtcNow);
Assert.Equal("nur Plus", active.Extra);
Assert.Equal(0, active.RunCount);
// Veraltete Version → 409 mit aktuellem Stand
var stale = await parent.PutAsJsonAsync($"/api/learning/schedules/{created.Id}", Plan(child.Id) with { Version = created.Version }, Ct);
Assert.Equal(HttpStatusCode.Conflict, stale.StatusCode);
Assert.True((await stale.Content.ReadFromJsonAsync<LearningScheduleDto>(Ct))!.IsActive);
Assert.Equal(HttpStatusCode.NotFound, (await parent.PutAsJsonAsync($"/api/learning/schedules/{Guid.NewGuid()}", Plan(child.Id), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await RunAsync(parent, Guid.NewGuid())).StatusCode);
}
finally
{
await DeleteAsync(parent, created.Id);
}
Assert.DoesNotContain((await parent.GetFromJsonAsync<List<LearningScheduleDto>>("/api/learning/schedules", Ct))!, s => s.Id == created.Id);
Assert.Equal(HttpStatusCode.NotFound, (await RunAsync(parent, created.Id)).StatusCode);
}
// ---------- Jetzt ausführen ----------
[Fact]
public async Task Run_now_creates_a_draft_the_child_cannot_see_with_rising_difficulty_and_audit_without_content()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
await using var server = FakeAiServer.Start();
server.Respond = _ => (200, OllamaEnvelope(GoodSheet), TimeSpan.Zero);
await ConfigureAsync(admin, server.BaseUrl);
var (child, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (_, parent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
using var _k = kid;
using var _p = parent;
var plan = await CreateAsync(parent, Plan(child.Id, raiseEvery: 1, extra: ExtraMarker));
try
{
var first = await RunAsync(parent, plan.Id);
Assert.True(first.StatusCode == HttpStatusCode.OK, await first.Content.ReadAsStringAsync(Ct));
var result = (await first.Content.ReadFromJsonAsync<LearningScheduleRunResultDto>(Ct))!;
Assert.False(result.Assigned);
Assert.Equal((1, LearningScheduleStatus.Created, 2), (result.Schedule.RunCount, result.Schedule.LastStatus, result.Schedule.CurrentDifficulty));
Assert.Equal(result.SetId, result.Schedule.LastSetId);
// Entwurf mit Herkunft KI; das Kind sieht ihn nicht
var detail = await GetSetAsync(parent, result.SetId);
Assert.Equal((LearningSetStatus.Draft, LearningSource.Ai, child.Id), (detail.Set.Status, detail.Set.Source, detail.Set.TargetUserId));
Assert.Contains("(", detail.Set.Title); // Datum im Titel
Assert.DoesNotContain(await MineAsync(kid), s => s.Id == result.SetId);
// Stufe 1 im Prompt beim ersten, Stufe 2 beim zweiten Blatt (eine Stufe je erzeugtem Blatt)
Assert.Contains("durchgehend leicht", SystemPrompt(server.Requests[0].Body));
Assert.Equal(HttpStatusCode.OK, (await RunAsync(parent, plan.Id)).StatusCode);
Assert.Contains("durchgehend mittel", SystemPrompt(server.Requests[1].Body));
Assert.Equal(2, (await GetAsync(parent, plan.Id)).RunCount);
Assert.Equal(3, (await GetAsync(parent, plan.Id)).CurrentDifficulty);
// Audit: Lauf ohne Inhalte (kein Wunsch, keine Aufgaben)
var audit = await admin.GetStringAsync("/api/admin/audit?page=1&pageSize=100", Ct);
Assert.Contains("learning.schedule_run", audit);
Assert.DoesNotContain(ExtraMarker, audit);
Assert.DoesNotContain("9423", audit);
}
finally
{
await DeleteAsync(parent, plan.Id);
}
}
[Fact]
public async Task Direct_assignment_reaches_the_child_but_too_few_valid_tasks_fall_back_to_a_draft()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
await using var server = FakeAiServer.Start();
server.Respond = _ => (200, OllamaEnvelope(GoodSheet), TimeSpan.Zero);
await ConfigureAsync(admin, server.BaseUrl);
var (child, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (_, parent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
using var _k = kid;
using var _p = parent;
var direct = await CreateAsync(parent, Plan(child.Id, count: 3, autoAssign: true));
var greedy = await CreateAsync(parent, Plan(child.Id, count: 10, autoAssign: true)); // die KI liefert nur 3 von 10
try
{
var assigned = (await (await RunAsync(parent, direct.Id)).Content.ReadFromJsonAsync<LearningScheduleRunResultDto>(Ct))!;
Assert.True(assigned.Assigned);
var mine = await MineAsync(kid);
var visible = mine.Single(s => s.Id == assigned.SetId);
Assert.Equal(LearningSetStatus.Assigned, visible.Status);
var childJson = await kid.GetStringAsync("/api/learning/my", Ct);
Assert.DoesNotContain("aiPrompt", childJson, StringComparison.OrdinalIgnoreCase); // keine Herkunft in Kinder-DTOs
Assert.DoesNotContain("scheduleId", childJson, StringComparison.OrdinalIgnoreCase);
var fallback = (await (await RunAsync(parent, greedy.Id)).Content.ReadFromJsonAsync<LearningScheduleRunResultDto>(Ct))!;
Assert.False(fallback.Assigned);
Assert.Equal(LearningScheduleStatus.CreatedAsDraft, fallback.Schedule.LastStatus);
Assert.Contains("brauchbar", fallback.Message);
Assert.DoesNotContain(await MineAsync(kid), s => s.Id == fallback.SetId);
Assert.Equal(LearningSetStatus.Draft, (await GetSetAsync(parent, fallback.SetId)).Set.Status);
}
finally
{
await DeleteAsync(parent, direct.Id);
await DeleteAsync(parent, greedy.Id);
}
}
[Fact]
public async Task Too_many_open_sheets_stop_further_generation_without_asking_the_ai()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
await using var server = FakeAiServer.Start();
server.Respond = _ => (200, OllamaEnvelope(GoodSheet), TimeSpan.Zero);
await ConfigureAsync(admin, server.BaseUrl);
var (child, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (_, parent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
using var _k = kid;
using var _p = parent;
var plan = await CreateAsync(parent, Plan(child.Id));
try
{
for (var i = 0; i < LearningSchedule.MaxOpenSheets; i++)
{
Assert.Equal(HttpStatusCode.OK, (await RunAsync(parent, plan.Id)).StatusCode);
}
var blocked = await RunAsync(parent, plan.Id);
Assert.Equal(HttpStatusCode.Conflict, blocked.StatusCode);
Assert.Contains("unerledigte", await blocked.Content.ReadAsStringAsync(Ct));
Assert.Equal(LearningSchedule.MaxOpenSheets, server.Requests.Count);
Assert.Equal(LearningScheduleStatus.Skipped, (await GetAsync(parent, plan.Id)).LastStatus);
}
finally
{
await DeleteAsync(parent, plan.Id);
}
}
[Fact]
public async Task The_daily_limit_defers_and_provider_errors_fail_with_a_generic_message()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
await using var server = FakeAiServer.Start();
server.Respond = _ => (200, OllamaEnvelope(GoodSheet), TimeSpan.Zero);
await ConfigureAsync(admin, server.BaseUrl, limit: 1);
var (child, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (_, parent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
using var _k = kid;
using var _p = parent;
var plan = await CreateAsync(parent, Plan(child.Id));
try
{
Assert.Equal(HttpStatusCode.OK, (await RunAsync(parent, plan.Id)).StatusCode);
var limited = await RunAsync(parent, plan.Id);
Assert.Equal(HttpStatusCode.TooManyRequests, limited.StatusCode);
var after = await GetAsync(parent, plan.Id);
Assert.Equal(LearningScheduleStatus.Deferred, after.LastStatus);
Assert.Contains("Tageslimit", after.LastMessage);
Assert.Equal(1, after.RunCount);
Assert.Single(server.Requests);
// Fehler des Anbieters: allgemeine Meldung, nie der Text des Anbieters
await ConfigureAsync(admin, server.BaseUrl);
server.Respond = _ => (500, "GEHEIMER-ANBIETERTEXT-77", TimeSpan.Zero);
var failed = await RunAsync(parent, plan.Id);
Assert.Equal(HttpStatusCode.BadGateway, failed.StatusCode);
var failedState = await GetAsync(parent, plan.Id);
Assert.Equal(LearningScheduleStatus.Failed, failedState.LastStatus);
Assert.DoesNotContain("GEHEIMER", failedState.LastMessage);
Assert.DoesNotContain("GEHEIMER", await failed.Content.ReadAsStringAsync(Ct));
Assert.Equal(1, failedState.RunCount);
// Ohne Anbieter: 409 mit verständlicher Meldung
await ClearProvidersAsync(admin);
var none = await RunAsync(parent, plan.Id);
Assert.Equal(HttpStatusCode.Conflict, none.StatusCode);
Assert.Contains("KI-Anbieter", await none.Content.ReadAsStringAsync(Ct));
}
finally
{
await DeleteAsync(parent, plan.Id);
}
}
// ---------- Hintergrunddienst (echte Uhr) ----------
[Fact]
public async Task The_background_service_creates_the_sheet_once_when_the_slot_arrives()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
await using var server = FakeAiServer.Start();
server.Respond = _ => (200, OllamaEnvelope(GoodSheet), TimeSpan.Zero);
await ConfigureAsync(admin, server.BaseUrl);
var (child, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (_, parent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
using var _k = kid;
using var _p = parent;
// Termin in der nächsten vollen Minute (Wanduhr der Familienzeitzone); angelegt wird der Plan davor, damit er nicht sofort feuert.
var local = FamilyTime.ToFamilyTime(DateTimeOffset.UtcNow);
var slotTime = new TimeOnly(local.Hour, local.Minute).AddMinutes(1);
var plan = await CreateAsync(parent, Plan(child.Id, active: true) with { TimeOfDay = slotTime });
try
{
Assert.Equal(LearningScheduleStatus.Never, plan.LastStatus);
LearningScheduleDto state = plan;
var deadline = DateTimeOffset.UtcNow.AddMinutes(3);
while (state.LastSetId is null && DateTimeOffset.UtcNow < deadline)
{
await Task.Delay(TimeSpan.FromSeconds(5), Ct);
state = await GetAsync(parent, plan.Id);
}
Assert.NotNull(state.LastSetId);
Assert.Equal((LearningScheduleStatus.Created, 1), (state.LastStatus, state.RunCount));
Assert.Equal(LearningSetStatus.Draft, (await GetSetAsync(parent, state.LastSetId!.Value)).Set.Status);
// Kein Doppellauf: weitere Takte erzeugen kein zweites Blatt für denselben Termin
await Task.Delay(TimeSpan.FromSeconds(75), Ct);
Assert.Equal(1, (await GetAsync(parent, plan.Id)).RunCount);
Assert.Single(server.Requests);
}
finally
{
await DeleteAsync(parent, plan.Id);
}
}
}