using Health.Application.Medications; using Health.Application.AI; using Health.Domain.Entities; using Health.Domain.Enums; using Health.Infrastructure.AI; using Health.Infrastructure.Data; using Health.Infrastructure.Data.Records; using Health.Infrastructure.Medications; using Health.Infrastructure.Notifications; using Health.Infrastructure.Reports; using Microsoft.EntityFrameworkCore; namespace Health.Tests; public sealed class PersistencePipelineTests { [Fact] public async Task ConversationCleanup_DeletesOnlyLegacyDietCommentaryArtifacts() { await using var db = CreateDbContext(); var userId = Guid.NewGuid(); var artifact = new Conversation { Id = Guid.NewGuid(), UserId = userId, AgentType = AgentType.Default, }; artifact.Messages.Add(new ConversationMessage { Id = Guid.NewGuid(), ConversationId = artifact.Id, Role = MessageRole.User, Content = DietCommentaryPolicy.LegacyPromptPrefix + "食物为:米饭", }); var normal = new Conversation { Id = Guid.NewGuid(), UserId = userId, AgentType = AgentType.Default, }; normal.Messages.Add(new ConversationMessage { Id = Guid.NewGuid(), ConversationId = normal.Id, Role = MessageRole.User, Content = "请分析我今天吃的米饭", }); db.Conversations.AddRange(artifact, normal); await db.SaveChangesAsync(); var repository = new EfAiConversationRepository(db); var deleted = await repository.DeleteLegacyDietCommentaryArtifactsAsync( userId, DietCommentaryPolicy.LegacyPromptPrefix, CancellationToken.None); Assert.Equal(1, deleted); Assert.Equal(normal.Id, Assert.Single(db.Conversations).Id); } [Fact] public async Task AiConfirmation_CanOnlyBeTakenOnceByOwner() { await using var db = CreateDbContext(); var store = new EfAiWriteConfirmationStore(db); var ownerId = Guid.NewGuid(); var command = await store.CreateAsync(ownerId, "record_health_data", "{}", TimeSpan.FromMinutes(10), CancellationToken.None); var wrongUser = await store.TakeAsync(command.Id, Guid.NewGuid(), CancellationToken.None); var firstTake = await store.TakeAsync(command.Id, ownerId, CancellationToken.None); var secondTake = await store.TakeAsync(command.Id, ownerId, CancellationToken.None); Assert.Null(wrongUser); Assert.NotNull(firstTake); Assert.Null(secondTake); } [Fact] public async Task ReportTask_RetryUsesBackoffBeforeMaxAttempts() { await using var db = CreateDbContext(); var task = NewReportTask(attempts: 1); db.ReportAnalysisTasks.Add(task); await db.SaveChangesAsync(); var queue = new EfReportAnalysisQueue(db); var before = DateTime.UtcNow; await queue.RetryAsync(task.Id, "temporary failure", CancellationToken.None); Assert.Equal("Pending", task.Status); Assert.True(task.AvailableAt > before); Assert.Equal("temporary failure", task.LastError); } [Fact] public async Task ReportTask_RetryMarksFailedAtMaxAttempts() { await using var db = CreateDbContext(); var task = NewReportTask(attempts: 3); db.ReportAnalysisTasks.Add(task); await db.SaveChangesAsync(); var queue = new EfReportAnalysisQueue(db); await queue.RetryAsync(task.Id, new string('x', 2500), CancellationToken.None); Assert.Equal("Failed", task.Status); Assert.Equal(2000, task.LastError!.Length); } [Fact] public async Task ReportTask_OnlyEnqueuesFailureNotificationAtFinalAttempt() { await using var db = CreateDbContext(); var userId = Guid.NewGuid(); var report = new Report { Id = Guid.NewGuid(), UserId = userId, FileUrl = "report.jpg", Status = ReportStatus.Analyzing, }; var task = NewReportTask(attempts: 2); task.ReportId = report.Id; db.Reports.Add(report); db.ReportAnalysisTasks.Add(task); await db.SaveChangesAsync(); var queue = new EfReportAnalysisQueue(db, new EfUserNotificationProducer(db)); await queue.RetryAsync(task.Id, "temporary failure", CancellationToken.None); Assert.Empty(db.NotificationOutbox); task.Status = "Processing"; task.Attempts = 3; await db.SaveChangesAsync(); await queue.RetryAsync(task.Id, "final failure", CancellationToken.None); var notification = Assert.Single(db.NotificationOutbox); Assert.Equal(task.Id, notification.SourceTaskId); Assert.Equal("ReportAnalysisFailed", notification.Type); Assert.Equal(userId, notification.UserId); } [Fact] public async Task MedicationReminder_MorningDoseDoesNotSuppressEveningDose() { await using var db = CreateDbContext(); var userId = Guid.NewGuid(); var medicationId = Guid.NewGuid(); var scanTimeUtc = new DateTime(2026, 6, 20, 12, 2, 0, DateTimeKind.Utc); db.Medications.Add(new Medication { Id = medicationId, UserId = userId, Name = "测试药物", Frequency = MedicationFrequency.TwiceDaily, TimeOfDay = [new TimeOnly(8, 0), new TimeOnly(20, 0)], StartDate = new DateOnly(2026, 6, 20), IsActive = true, }); db.MedicationLogs.Add(new MedicationLog { Id = Guid.NewGuid(), UserId = userId, MedicationId = medicationId, Status = MedicationLogStatus.Taken, ScheduledTime = new TimeOnly(8, 0), ConfirmedAt = new DateTime(2026, 6, 20, 0, 1, 0, DateTimeKind.Utc), CreatedAt = new DateTime(2026, 6, 20, 0, 1, 0, DateTimeKind.Utc), }); await db.SaveChangesAsync(); var scanner = new MedicationReminderScanner(new EfMedicationRepository(db)); var tasks = await scanner.ScanAsync(scanTimeUtc, CancellationToken.None); var reminder = Assert.Single(tasks); Assert.Equal(new TimeOnly(20, 0), reminder.ScheduledTime); } [Fact] public async Task MedicationReminder_ConfirmedDoseIsNotRemindedAgain() { await using var db = CreateDbContext(); var userId = Guid.NewGuid(); var medicationId = Guid.NewGuid(); var confirmedAt = new DateTime(2026, 6, 20, 12, 0, 0, DateTimeKind.Utc); db.Medications.Add(new Medication { Id = medicationId, UserId = userId, Name = "测试药物", Frequency = MedicationFrequency.Daily, TimeOfDay = [new TimeOnly(20, 0)], StartDate = new DateOnly(2026, 6, 20), IsActive = true, }); db.MedicationLogs.Add(new MedicationLog { Id = Guid.NewGuid(), UserId = userId, MedicationId = medicationId, Status = MedicationLogStatus.Taken, ScheduledTime = new TimeOnly(20, 0), ConfirmedAt = confirmedAt, CreatedAt = confirmedAt, }); await db.SaveChangesAsync(); var scanner = new MedicationReminderScanner(new EfMedicationRepository(db)); var tasks = await scanner.ScanAsync( new DateTime(2026, 6, 20, 12, 2, 0, DateTimeKind.Utc), CancellationToken.None); Assert.Empty(tasks); } [Fact] public async Task NotificationOutbox_PushDisabledDoesNotCreateInAppNotification() { await using var db = CreateDbContext(); var userId = Guid.NewGuid(); var sourceId = Guid.NewGuid(); db.NotificationPreferences.Add(new NotificationPreference { Id = Guid.NewGuid(), UserId = userId, PushEnabled = false, MedicationReminder = true, HealthRecordReminder = false, }); db.NotificationOutbox.Add(new NotificationOutboxRecord { Id = Guid.NewGuid(), UserId = userId, SourceTaskId = sourceId, Type = "MedicationReminder", Payload = """ {"Type":"MedicationReminder","Title":"用药时间到了","Message":"请按计划服药","Severity":"info","ActionType":"medication","ActionTargetId":null} """, Status = "Pending", AvailableAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, }); await db.SaveChangesAsync(); var processor = new EfNotificationOutboxProcessor(db); await processor.ProcessNextAsync(CancellationToken.None); Assert.Empty(db.UserNotifications); } [Fact] public async Task ReminderCatchUp_PushDisabledDoesNotCreateHealthRecordReminder() { await using var db = CreateDbContext(); var userId = Guid.NewGuid(); db.NotificationPreferences.Add(new NotificationPreference { Id = Guid.NewGuid(), UserId = userId, PushEnabled = false, HealthRecordReminder = true, HealthRecordReminderBloodPressure = true, HealthRecordReminderHeartRate = false, HealthRecordReminderGlucose = false, HealthRecordReminderSpO2 = false, HealthRecordReminderWeight = false, }); await db.SaveChangesAsync(); var service = new EfReminderCatchUpService(db); var nowUtc = new DateTime(2026, 7, 13, 5, 0, 0, DateTimeKind.Utc); // 13:00 Beijing var created = await service.CheckDueAsync(userId, nowUtc, CancellationToken.None); var secondPass = await service.CheckDueAsync(userId, nowUtc, CancellationToken.None); Assert.Equal(0, created.CreatedCount); Assert.Equal(0, secondPass.CreatedCount); Assert.Empty(db.UserNotifications); } [Fact] public async Task ReminderCatchUp_PushEnabledCreatesOnlySelectedMissingMetric() { await using var db = CreateDbContext(); var userId = Guid.NewGuid(); db.NotificationPreferences.Add(new NotificationPreference { Id = Guid.NewGuid(), UserId = userId, PushEnabled = true, HealthRecordReminder = true, HealthRecordReminderBloodPressure = true, HealthRecordReminderHeartRate = false, HealthRecordReminderGlucose = false, HealthRecordReminderSpO2 = false, HealthRecordReminderWeight = false, }); await db.SaveChangesAsync(); var service = new EfReminderCatchUpService(db); var nowUtc = new DateTime(2026, 7, 13, 5, 0, 0, DateTimeKind.Utc); var result = await service.CheckDueAsync(userId, nowUtc, CancellationToken.None); Assert.Equal(1, result.CreatedCount); var notification = Assert.Single(db.UserNotifications); Assert.Contains("血压", notification.Message); Assert.DoesNotContain("心率", notification.Message); } [Fact] public async Task ReminderCatchUp_DndStillPreventsReminder() { await using var db = CreateDbContext(); var userId = Guid.NewGuid(); db.NotificationPreferences.Add(new NotificationPreference { Id = Guid.NewGuid(), UserId = userId, PushEnabled = true, HealthRecordReminder = true, HealthRecordReminderBloodPressure = true, DndEnabled = true, DndStartMinutes = 12 * 60, DndEndMinutes = 14 * 60, }); await db.SaveChangesAsync(); var service = new EfReminderCatchUpService(db); var nowUtc = new DateTime(2026, 7, 13, 5, 0, 0, DateTimeKind.Utc); var result = await service.CheckDueAsync(userId, nowUtc, CancellationToken.None); Assert.Equal(0, result.CreatedCount); Assert.Empty(db.UserNotifications); } [Fact] public async Task ReminderCatchUp_CreatesMedicationAndExerciseRemindersForDueItems() { await using var db = CreateDbContext(); var userId = Guid.NewGuid(); var date = new DateOnly(2026, 7, 13); db.NotificationPreferences.Add(new NotificationPreference { Id = Guid.NewGuid(), UserId = userId, PushEnabled = true, MedicationReminder = true, HealthRecordReminder = false, }); db.Medications.Add(new Medication { Id = Guid.NewGuid(), UserId = userId, Name = "测试药物", Dosage = "1片", Frequency = MedicationFrequency.Daily, TimeOfDay = [new TimeOnly(8, 0)], StartDate = date, IsActive = true, }); var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, StartDate = date, EndDate = date, ReminderTime = new TimeOnly(9, 0), }; plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = date, ExerciseType = "散步", DurationMinutes = 30, }); db.ExercisePlans.Add(plan); await db.SaveChangesAsync(); var service = new EfReminderCatchUpService(db); var nowUtc = new DateTime(2026, 7, 13, 2, 0, 0, DateTimeKind.Utc); // 10:00 Beijing var created = await service.CheckDueAsync(userId, nowUtc, CancellationToken.None); Assert.Equal(2, created.CreatedCount); Assert.Contains(db.UserNotifications, x => x.Type == "MedicationReminder"); Assert.Contains(db.UserNotifications, x => x.Type == "ExerciseReminder"); } private static ReportAnalysisTaskRecord NewReportTask(int attempts) => new() { Id = Guid.NewGuid(), ReportId = Guid.NewGuid(), FilePath = "report.jpg", Status = "Processing", Attempts = attempts, AvailableAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, }; private static AppDbContext CreateDbContext() => new( new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options); }