test: integration tests for learning schedules
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user