using Health.Application.AI; using Health.Application.Calendars; using Health.Application.HealthArchives; using Health.Application.HealthRecords; using Health.Application.Exercises; using Health.Domain; using Health.Domain.Entities; using Health.Domain.Enums; using Health.Infrastructure.AI.AgentHandlers; using System.Text.Json; namespace Health.Tests; public sealed class ApplicationServiceTests { [Fact] public async Task HealthArchive_AiUpdate_OnlyChangesRequestedSection() { var userId = Guid.NewGuid(); var repository = new FakeHealthArchiveRepository(new HealthArchive { Id = Guid.NewGuid(), UserId = userId, Diagnosis = "原诊断", Allergies = ["青霉素"] }); var service = new HealthArchiveService(repository); var result = await service.ExecuteAiActionAsync( userId, "update_diagnosis", new HealthArchiveUpdateRequest("新诊断", "不应写入的手术", null, null, null, null, null), CancellationToken.None); Assert.True((bool)result.GetType().GetProperty("success")!.GetValue(result)!); Assert.Equal("新诊断", repository.Archive!.Diagnosis); Assert.Null(repository.Archive.SurgeryType); Assert.Equal(["青霉素"], repository.Archive.Allergies); } [Fact] public async Task HealthArchive_ManualUpdateStoresMultipleStructuredSurgeries() { var userId = Guid.NewGuid(); var repository = new FakeHealthArchiveRepository(new HealthArchive { Id = Guid.NewGuid(), UserId = userId, }); var service = new HealthArchiveService(repository); var result = await service.UpdateAsync( userId, new HealthArchiveUpdateRequest( null, null, null, null, null, null, null, [ new HealthArchiveSurgeryInput("PCI", new DateOnly(2025, 3, 1)), new HealthArchiveSurgeryInput("Appendectomy", new DateOnly(2010, 6, 2)), ]), CancellationToken.None); Assert.Equal(2, result.Surgeries.Count); Assert.Equal("PCI", result.SurgeryType); Assert.Equal(new DateOnly(2025, 3, 1), result.SurgeryDate); Assert.Equal([0, 1], repository.Archive!.Surgeries.OrderBy(s => s.SortOrder).Select(s => s.SortOrder)); } [Fact] public async Task HealthTrend_365DaysUsesFullYearWindow() { var repository = new CapturingHealthRecordRepository(); var service = new HealthRecordService(repository); var before = DateTime.UtcNow; await service.GetTrendAsync(Guid.NewGuid(), HealthMetricType.Weight, 365, CancellationToken.None); Assert.NotNull(repository.RecordedAfter); Assert.InRange((before - repository.RecordedAfter!.Value).TotalDays, 364.99, 365.01); } [Fact] public async Task HealthRecords_BatchPersistsAllReadingsWithOneSave() { var repository = new CapturingHealthRecordRepository(); var service = new HealthRecordService(repository); var ids = await service.CreateManyAsync( Guid.NewGuid(), [ new HealthRecordUpsertRequest(HealthMetricType.BloodPressure, 116, 89, null, "mmHg", HealthRecordSource.DeviceSync, DateTime.UtcNow), new HealthRecordUpsertRequest(HealthMetricType.HeartRate, null, null, 72, "bpm", HealthRecordSource.DeviceSync, DateTime.UtcNow), ], CancellationToken.None); Assert.Equal(2, ids.Count); Assert.Equal(2, repository.Added.Count); Assert.Equal(1, repository.SaveCount); } [Fact] public async Task HealthArchive_AiSurgeryUpdatePreservesLegacySurgery() { var userId = Guid.NewGuid(); var repository = new FakeHealthArchiveRepository(new HealthArchive { Id = Guid.NewGuid(), UserId = userId, SurgeryType = "Legacy surgery", SurgeryDate = new DateOnly(2010, 1, 2), }); var service = new HealthArchiveService(repository); await service.ExecuteAiActionAsync( userId, "update_surgery", new HealthArchiveUpdateRequest(null, "New surgery", new DateOnly(2026, 6, 20), null, null, null, null), CancellationToken.None); Assert.Equal(2, repository.Archive!.Surgeries.Count); Assert.Contains(repository.Archive.Surgeries, s => s.Type == "Legacy surgery"); Assert.Contains(repository.Archive.Surgeries, s => s.Type == "New surgery"); } [Fact] public async Task Conversation_Open_DoesNotReturnAnotherUsersConversation() { var ownerId = Guid.NewGuid(); var conversation = new Conversation { Id = Guid.NewGuid(), UserId = ownerId, AgentType = AgentType.Health }; var service = new AiConversationService(new FakeConversationRepository(conversation)); var result = await service.OpenAsync(Guid.NewGuid(), conversation.Id, AgentType.Health, "测试", CancellationToken.None); Assert.False(result.Found); Assert.False(result.Created); } [Fact] public async Task Conversation_List_ReturnsAtMostThirtyRecentItems() { var userId = Guid.NewGuid(); var conversations = Enumerable.Range(0, 35) .Select(index => new Conversation { Id = Guid.NewGuid(), UserId = userId, AgentType = AgentType.Unified, UpdatedAt = DateTime.UtcNow.AddMinutes(-index), }) .ToList(); var service = new AiConversationService(new ListConversationRepository(conversations)); var result = await service.ListAsync(userId, CancellationToken.None); Assert.Equal(30, result.Count); } [Fact] public void Conversation_Delete_ReportsWhetherARecordWasActuallyDeleted() { var method = typeof(IAiConversationService).GetMethod(nameof(IAiConversationService.DeleteAsync)); Assert.NotNull(method); Assert.Equal(typeof(Task), method!.ReturnType); } [Fact] public async Task Conversation_Open_ReactivatesTheSameOwnedConversation() { var userId = Guid.NewGuid(); var conversation = new Conversation { Id = Guid.NewGuid(), UserId = userId, AgentType = AgentType.Unified, UpdatedAt = DateTime.UtcNow.AddDays(-1), }; var service = new AiConversationService(new FakeConversationRepository(conversation)); var result = await service.OpenAsync( userId, conversation.Id, AgentType.Unified, "新的追问", CancellationToken.None); Assert.True(result.Found); Assert.False(result.Created); Assert.Equal(conversation.Id, result.ConversationId); } [Fact] public async Task Calendar_Month_AggregatesMedicationExerciseAndFollowUp() { var date = new DateOnly(2026, 6, 19); var medication = new Medication { Id = Guid.NewGuid(), IsActive = true, Name = "阿司匹林", StartDate = date.AddDays(-1), TimeOfDay = [new TimeOnly(8, 0)] }; var plan = new ExercisePlan { Id = Guid.NewGuid(), StartDate = date, EndDate = date, ReminderTime = new TimeOnly(19, 0) }; plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = date, ExerciseType = "散步", DurationMinutes = 30 }); var followUp = new FollowUp { Id = Guid.NewGuid(), ScheduledAt = date.ToDateTime(new TimeOnly(9, 0)), Title = "复查" }; var service = new CalendarService(new FakeCalendarRepository(new CalendarDataSnapshot([medication], [plan], [followUp]))); var result = await service.GetMonthAsync(Guid.NewGuid(), 2026, 6, CancellationToken.None); var target = Assert.Single(result, item => item.GetType().GetProperty("date")!.GetValue(item)?.ToString() == "2026-06-19"); var events = (IEnumerable)target.GetType().GetProperty("events")!.GetValue(target)!; Assert.Equal(["medication", "exercise", "followup"], events); } [Fact] public async Task Calendar_FollowUpUsesBeijingDateForUtcTimestamp() { var followUp = new FollowUp { Id = Guid.NewGuid(), ScheduledAt = new DateTime(2026, 6, 18, 17, 0, 0, DateTimeKind.Utc), Title = "北京时间复查" }; var service = new CalendarService(new FakeCalendarRepository( new CalendarDataSnapshot([], [], [followUp]))); var result = await service.GetMonthAsync(Guid.NewGuid(), 2026, 6, CancellationToken.None); var target = Assert.Single(result); Assert.Equal("2026-06-19", target.GetType().GetProperty("date")!.GetValue(target)); } [Fact] public async Task ExercisePlan_TenDays_CreatesTenUniqueConsecutiveDates() { var repository = new FakeExerciseRepository(); var service = new ExerciseService(repository); var start = new DateOnly(2026, 6, 20); await service.CreateAsync(Guid.NewGuid(), new ExercisePlanCreateRequest(start, start.AddDays(9), "跑步", 30, new TimeOnly(19, 0)), CancellationToken.None); var plan = Assert.IsType(repository.Plan); Assert.Equal(start.AddDays(9), plan.EndDate); Assert.Equal(10, plan.Items.Count); Assert.Equal(10, plan.Items.Select(x => x.ScheduledDate).Distinct().Count()); Assert.Equal(Enumerable.Range(0, 10).Select(start.AddDays), plan.Items.OrderBy(x => x.ScheduledDate).Select(x => x.ScheduledDate)); } [Fact] public async Task ExercisePlan_CurrentUsesExactScheduledDate() { var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); var repository = new FakeExerciseRepository(); var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = Guid.NewGuid(), StartDate = today, EndDate = today.AddDays(9), ReminderTime = new TimeOnly(19, 0), }; plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = today, ExerciseType = "跑步", DurationMinutes = 30 }); plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = today.AddDays(7), ExerciseType = "跑步", DurationMinutes = 30 }); repository.SetPlan(plan); var service = new ExerciseService(repository); var current = await service.GetCurrentAsync(plan.UserId, CancellationToken.None); Assert.NotNull(current); Assert.Equal(today, current!.Items.Single(x => x.ScheduledDate == today).ScheduledDate); Assert.Equal(2, current.Items.Count); } [Fact] public async Task AiExerciseQuery_UpcomingScopeReturnsFuturePlanButTodayScopeDoesNot() { var userId = Guid.NewGuid(); var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); var repository = new FakeExerciseRepository(); var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, StartDate = today.AddDays(2), EndDate = today.AddDays(4), ReminderTime = new TimeOnly(19, 0), }; plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = today.AddDays(2), ExerciseType = "散步", DurationMinutes = 30, }); repository.SetPlan(plan); var service = new ExerciseService(repository); using var upcomingArgs = JsonDocument.Parse("""{"action":"query","scope":"upcoming_plans"}"""); using var todayArgs = JsonDocument.Parse("""{"action":"query","scope":"today"}"""); var upcoming = await ExerciseAgentHandler.Execute( "manage_exercise", upcomingArgs.RootElement, userId, service, CancellationToken.None); var todayResult = await ExerciseAgentHandler.Execute( "manage_exercise", todayArgs.RootElement, userId, service, CancellationToken.None); var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; using var upcomingJson = JsonDocument.Parse(JsonSerializer.Serialize(upcoming, jsonOptions)); using var todayJson = JsonDocument.Parse(JsonSerializer.Serialize(todayResult, jsonOptions)); Assert.Equal(1, upcomingJson.RootElement.GetProperty("count").GetInt32()); Assert.Equal(2, upcomingJson.RootElement.GetProperty("days_until_next_start").GetInt32()); Assert.Equal(0, todayJson.RootElement.GetProperty("count").GetInt32()); } [Fact] public async Task AiExerciseQuery_RequiresExplicitScopeInsteadOfAssumingToday() { var userId = Guid.NewGuid(); var service = new ExerciseService(new FakeExerciseRepository()); using var args = JsonDocument.Parse("""{"action":"query"}"""); var result = await ExerciseAgentHandler.Execute( "manage_exercise", args.RootElement, 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 AiFollowUpQuery_RequiresExplicitScopeInsteadOfAssumingNext() { using var args = JsonDocument.Parse("""{}"""); var result = await PatientReadAgentHandler.QueryFollowUpsAsync( null!, Guid.NewGuid(), args.RootElement, 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 ExercisePlan_CheckInRejectsNonTodayItem() { var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); var repository = new FakeExerciseRepository(); var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = Guid.NewGuid(), StartDate = today.AddDays(-1), EndDate = today, ReminderTime = new TimeOnly(19, 0), }; var item = new ExercisePlanItem { Id = Guid.NewGuid(), Plan = plan, ScheduledDate = today.AddDays(-1), ExerciseType = "散步", DurationMinutes = 30, }; plan.Items.Add(item); repository.SetPlan(plan); var service = new ExerciseService(repository); await Assert.ThrowsAsync(() => service.ToggleCheckInAsync(plan.UserId, item.Id, CancellationToken.None)); } private sealed class FakeHealthArchiveRepository(HealthArchive? archive) : IHealthArchiveRepository { public HealthArchive? Archive { get; private set; } = archive; public Task GetByUserIdAsync(Guid userId, CancellationToken ct) => Task.FromResult(Archive?.UserId == userId ? Archive : null); public Task AddAsync(HealthArchive value, CancellationToken ct) { Archive = value; return Task.CompletedTask; } public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask; } private sealed class CapturingHealthRecordRepository : IHealthRecordRepository { public DateTime? RecordedAfter { get; private set; } public List Added { get; } = []; public int SaveCount { get; private set; } public Task> ListAsync(Guid userId, HealthMetricType? type, DateTime? recordedAfter, int limit, CancellationToken ct) => Task.FromResult>([]); public Task GetOwnedAsync(Guid userId, Guid id, CancellationToken ct) => Task.FromResult(null); public Task GetLatestByTypeAsync(Guid userId, HealthMetricType type, CancellationToken ct) => Task.FromResult(null); public Task> GetTrendAsync(Guid userId, HealthMetricType type, DateTime recordedAfter, CancellationToken ct) { RecordedAfter = recordedAfter; return Task.FromResult>([]); } public Task AddAsync(HealthRecord record, CancellationToken ct) { Added.Add(record); return Task.CompletedTask; } public void Delete(HealthRecord record) { } public Task SaveChangesAsync(CancellationToken ct) { SaveCount++; return Task.CompletedTask; } } private sealed class FakeConversationRepository(Conversation conversation) : IAiConversationRepository { private readonly Conversation _conversation = conversation; public Task GetOwnedAsync(Guid userId, Guid conversationId, CancellationToken ct) => Task.FromResult(_conversation.Id == conversationId && _conversation.UserId == userId ? _conversation : null); public Task GetAsync(Guid conversationId, CancellationToken ct) => Task.FromResult(_conversation.Id == conversationId ? _conversation : null); public Task> ListAsync(Guid userId, CancellationToken ct) => Task.FromResult>([]); public Task> GetMessagesAsync(Guid conversationId, CancellationToken ct) => Task.FromResult>([]); public Task> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct) => Task.FromResult>([]); public Task AddConversationAsync(Conversation value, CancellationToken ct) => Task.CompletedTask; public Task AddMessageAsync(ConversationMessage message, CancellationToken ct) => Task.CompletedTask; public Task DeleteLegacyDietCommentaryArtifactsAsync(Guid userId, string promptPrefix, CancellationToken ct) => Task.FromResult(0); public void Delete(Conversation value) { } public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask; } private sealed class ListConversationRepository(List conversations) : IAiConversationRepository { private readonly List _conversations = conversations; public Task GetOwnedAsync(Guid userId, Guid conversationId, CancellationToken ct) => Task.FromResult(_conversations.FirstOrDefault(item => item.Id == conversationId && item.UserId == userId)); public Task GetAsync(Guid conversationId, CancellationToken ct) => Task.FromResult(_conversations.FirstOrDefault(item => item.Id == conversationId)); public Task> ListAsync(Guid userId, CancellationToken ct) => Task.FromResult>(_conversations .Where(item => item.UserId == userId) .OrderByDescending(item => item.UpdatedAt) .ToList()); public Task> GetMessagesAsync(Guid conversationId, CancellationToken ct) => Task.FromResult>([]); public Task> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct) => Task.FromResult>([]); public Task AddConversationAsync(Conversation value, CancellationToken ct) { _conversations.Add(value); return Task.CompletedTask; } public Task AddMessageAsync(ConversationMessage message, CancellationToken ct) => Task.CompletedTask; public Task DeleteLegacyDietCommentaryArtifactsAsync(Guid userId, string promptPrefix, CancellationToken ct) => Task.FromResult(0); public void Delete(Conversation value) => _conversations.Remove(value); public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask; } private sealed class FakeCalendarRepository(CalendarDataSnapshot snapshot) : ICalendarRepository { public Task GetSnapshotAsync(Guid userId, DateOnly start, DateOnly end, CancellationToken ct) => Task.FromResult(snapshot); } private sealed class FakeExerciseRepository : IExerciseRepository { public ExercisePlan? Plan { get; private set; } public void SetPlan(ExercisePlan plan) => Plan = plan; public Task> ListActiveOnAsync(Guid userId, DateOnly date, CancellationToken ct) => Task.FromResult>( Plan != null && Plan.UserId == userId && Plan.StartDate <= date && Plan.EndDate >= date ? [Plan] : []); public Task> ListAsync(Guid userId, int limit, CancellationToken ct) => Task.FromResult>( Plan != null && Plan.UserId == userId ? [Plan] : []); public Task> ListAllAsync(Guid userId, CancellationToken ct) => Task.FromResult>( Plan != null && Plan.UserId == userId ? [Plan] : []); public Task GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct) => Task.FromResult(Plan); public Task GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct) => Task.FromResult(Plan?.Items.FirstOrDefault(x => x.Id == itemId)); public Task AddAsync(ExercisePlan plan, CancellationToken ct) { Plan = plan; return Task.CompletedTask; } public void Delete(ExercisePlan plan) { Plan = null; } public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask; } }