using Health.Application.Medications; using Health.Domain.Entities; using Health.Domain.Enums; using Health.Infrastructure.AI.AgentHandlers; using System.Text.Json; namespace Health.Tests; public sealed class MedicationUpdateTests { [Fact] public async Task Update_CanClearAnExistingEndDate() { var medication = new Medication { Id = Guid.NewGuid(), UserId = Guid.NewGuid(), Name = "阿司匹林", StartDate = new DateOnly(2026, 7, 1), EndDate = new DateOnly(2026, 7, 31), }; var repository = new FakeMedicationRepository(medication); var service = new MedicationService(repository); var updated = await service.UpdateAsync( medication.UserId, medication.Id, new MedicationPatchRequest( null, null, null, null, null, null, null, ClearEndDate: true), CancellationToken.None); Assert.True(updated); Assert.Null(medication.EndDate); Assert.True(repository.Saved); } [Fact] public async Task AiOverview_DoesNotScheduleFutureMedicationForToday() { var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); var medication = new Medication { Id = Guid.NewGuid(), UserId = Guid.NewGuid(), Name = "未来用药", IsActive = true, StartDate = today.AddDays(2), EndDate = today.AddDays(10), Frequency = MedicationFrequency.Daily, TimeOfDay = [new TimeOnly(8, 0)], }; var service = new MedicationService(new FakeMedicationRepository(medication)); var overview = await service.GetAiOverviewAsync(medication.UserId, today, CancellationToken.None); var plan = Assert.Single(overview); Assert.Equal("upcoming", plan.Phase); Assert.False(plan.ScheduledOnDate); Assert.Empty(plan.Doses); } [Fact] public async Task AiOverview_ReportsEachDoseInsteadOfAnyTakenAggregate() { var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); var medication = new Medication { Id = Guid.NewGuid(), UserId = Guid.NewGuid(), Name = "两顿药", IsActive = true, StartDate = today.AddDays(-1), EndDate = today.AddDays(1), Frequency = MedicationFrequency.TwiceDaily, TimeOfDay = [new TimeOnly(8, 0), new TimeOnly(20, 0)], }; var log = new MedicationLog { Id = Guid.NewGuid(), UserId = medication.UserId, MedicationId = medication.Id, ScheduledTime = new TimeOnly(8, 0), Status = MedicationLogStatus.Taken, ConfirmedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow, }; var service = new MedicationService(new FakeMedicationRepository(medication, [log])); var plan = Assert.Single(await service.GetAiOverviewAsync(medication.UserId, today, CancellationToken.None)); Assert.Equal(2, plan.Doses.Count); Assert.Equal("taken", plan.Doses.Single(dose => dose.ScheduledTime == "08:00").Status); Assert.NotEqual("taken", plan.Doses.Single(dose => dose.ScheduledTime == "20:00").Status); } [Fact] public async Task ConfirmDose_RejectsAClockTimeThatIsNotInTodaysPlan() { var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); var medication = new Medication { Id = Guid.NewGuid(), UserId = Guid.NewGuid(), Name = "阿司匹林", IsActive = true, StartDate = today, Frequency = MedicationFrequency.Daily, TimeOfDay = [new TimeOnly(8, 0)], }; var repository = new FakeMedicationRepository(medication); var service = new MedicationService(repository); var result = await service.ConfirmDoseAsync( medication.UserId, medication.Id, new TimeOnly(8, 15), MedicationLogStatus.Taken, CancellationToken.None); Assert.False(result); Assert.Empty(repository.AddedLogs); } [Fact] public void AiMedicationCreate_RejectsMissingFieldsInsteadOfUsingDefaults() { using var arguments = JsonDocument.Parse("""{"action":"create","name":"阿司匹林"}"""); var error = MedicationAgentHandler.ValidateWriteArguments(arguments.RootElement); Assert.Equal("创建用药计划前需要确认每次剂量", error); } [Fact] public void AiMedicationCreate_AcceptsCompleteFinitePlan() { using var arguments = JsonDocument.Parse(""" { "action":"create", "name":"阿司匹林", "dosage":"100mg", "frequency":"Daily", "time_of_day":["08:00"], "start_date":"2026-07-20", "duration_days":30 } """); var error = MedicationAgentHandler.ValidateWriteArguments(arguments.RootElement); Assert.Null(error); } [Fact] public async Task AiMedicationQuery_UpcomingScopeReturnsTomorrowPlanButCurrentScopeDoesNot() { var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); var medication = new Medication { Id = Guid.NewGuid(), UserId = Guid.NewGuid(), Name = "明天开始的药", Dosage = "1片", IsActive = true, StartDate = today.AddDays(1), Frequency = MedicationFrequency.Daily, TimeOfDay = [new TimeOnly(8, 0)], }; var service = new MedicationService(new FakeMedicationRepository(medication)); using var upcomingArgs = JsonDocument.Parse("""{"action":"query","scope":"upcoming_plans"}"""); using var currentArgs = JsonDocument.Parse("""{"action":"query","scope":"current_plans"}"""); var upcoming = await MedicationAgentHandler.Execute( "manage_medication", upcomingArgs.RootElement, medication.UserId, service, CancellationToken.None); var current = await MedicationAgentHandler.Execute( "manage_medication", currentArgs.RootElement, medication.UserId, service, CancellationToken.None); var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; using var upcomingJson = JsonDocument.Parse(JsonSerializer.Serialize(upcoming, jsonOptions)); using var currentJson = JsonDocument.Parse(JsonSerializer.Serialize(current, jsonOptions)); Assert.Equal(1, upcomingJson.RootElement.GetProperty("count").GetInt32()); Assert.True(upcomingJson.RootElement.GetProperty("has_upcoming_plans").GetBoolean()); Assert.Equal(1, upcomingJson.RootElement.GetProperty("days_until_next_start").GetInt32()); Assert.Equal("明天开始的药", upcomingJson.RootElement.GetProperty("plans")[0].GetProperty("name").GetString()); Assert.Equal(0, currentJson.RootElement.GetProperty("count").GetInt32()); } [Fact] public async Task AiMedicationQuery_RequiresExplicitScopeInsteadOfAssumingToday() { var medication = new Medication { Id = Guid.NewGuid(), UserId = Guid.NewGuid(), Name = "测试药物", IsActive = true, StartDate = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), Frequency = MedicationFrequency.Daily, TimeOfDay = [new TimeOnly(8, 0)], }; var service = new MedicationService(new FakeMedicationRepository(medication)); using var args = JsonDocument.Parse("""{"action":"query"}"""); var result = await MedicationAgentHandler.Execute( "manage_medication", args.RootElement, medication.UserId, service, CancellationToken.None); using var json = JsonDocument.Parse(JsonSerializer.Serialize(result)); Assert.False(json.RootElement.GetProperty("success").GetBoolean()); Assert.Contains("scope", json.RootElement.GetProperty("message").GetString()); } [Fact] public async Task AiMedicationQuery_SeparatesInactivePlansFromNaturallyEndedPlans() { var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); var medication = new Medication { Id = Guid.NewGuid(), UserId = Guid.NewGuid(), Name = "已停用药物", IsActive = false, StartDate = today.AddDays(-10), EndDate = today.AddDays(10), Frequency = MedicationFrequency.Daily, TimeOfDay = [new TimeOnly(8, 0)], }; var service = new MedicationService(new FakeMedicationRepository(medication)); using var inactiveArgs = JsonDocument.Parse("""{"action":"query","scope":"inactive_plans"}"""); using var endedArgs = JsonDocument.Parse("""{"action":"query","scope":"ended_plans"}"""); var inactive = await MedicationAgentHandler.Execute( "manage_medication", inactiveArgs.RootElement, medication.UserId, service, CancellationToken.None); var ended = await MedicationAgentHandler.Execute( "manage_medication", endedArgs.RootElement, medication.UserId, service, CancellationToken.None); using var inactiveJson = JsonDocument.Parse(JsonSerializer.Serialize(inactive)); using var endedJson = JsonDocument.Parse(JsonSerializer.Serialize(ended)); Assert.Equal(1, inactiveJson.RootElement.GetProperty("count").GetInt32()); Assert.Equal(0, endedJson.RootElement.GetProperty("count").GetInt32()); } private sealed class FakeMedicationRepository( Medication medication, IReadOnlyList? logs = null) : IMedicationRepository { public bool Saved { get; private set; } public List AddedLogs { get; } = []; public Task> ListAsync(Guid userId, string? filter, CancellationToken ct) => Task.FromResult>([medication]); public Task> ListActiveForRemindersAsync(Guid userId, DateOnly today, CancellationToken ct) => Task.FromResult>([medication]); public Task> ListLogsInWindowAsync(Guid userId, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult(logs ?? (IReadOnlyList)[]); public Task GetOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) => Task.FromResult(medication.UserId == userId && medication.Id == medicationId ? medication : null); public Task DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult(false); public Task GetDoseLogAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult(null); public Task> ListActiveForReminderScanAsync(CancellationToken ct) => Task.FromResult>([medication]); public Task AddMedicationAsync(Medication value, CancellationToken ct) => Task.CompletedTask; public Task AddLogAsync(MedicationLog log, CancellationToken ct) { AddedLogs.Add(log); return Task.CompletedTask; } public void DeleteMedication(Medication value) { } public void DeleteLog(MedicationLog log) { } public Task SaveChangesAsync(CancellationToken ct) { Saved = true; return Task.CompletedTask; } } }