using Health.Application.Users; using Health.Domain.Entities; using Health.Infrastructure.Users; namespace Health.Tests; public sealed class AccountDeletionTests { [Fact] public async Task LocalCleanup_DeletesOwnedUploadsWithoutTouchingOtherFiles() { var root = Path.Combine(Path.GetTempPath(), $"health-account-delete-{Guid.NewGuid():N}"); var userId = Guid.NewGuid(); var userDirectory = Path.Combine(root, "users", userId.ToString("N")); var reportPath = Path.Combine(root, "reports", "owned-report.pdf"); var chatFileName = $"{Guid.NewGuid()}.jpg"; var chatImagePath = Path.Combine(userDirectory, chatFileName); var otherUserId = Guid.NewGuid(); var otherUserPath = Path.Combine(root, "users", otherUserId.ToString("N"), "keep.jpg"); var outsidePath = Path.Combine(Path.GetDirectoryName(root)!, "outside-account-file.txt"); try { Directory.CreateDirectory(userDirectory); Directory.CreateDirectory(Path.GetDirectoryName(reportPath)!); Directory.CreateDirectory(Path.GetDirectoryName(otherUserPath)!); await File.WriteAllTextAsync(Path.Combine(userDirectory, "unlinked-upload.jpg"), "owned"); await File.WriteAllTextAsync(reportPath, "owned"); await File.WriteAllTextAsync(chatImagePath, "owned"); await File.WriteAllTextAsync(otherUserPath, "other"); await File.WriteAllTextAsync(outsidePath, "outside"); var cleanup = new LocalAccountFileCleanup(root); var references = new AccountFileReferences( ["/uploads/reports/owned-report.pdf", "/uploads/../outside-account-file.txt"], [$"{{\"imageUrl\":\"/uploads/users/{userId:N}/{chatFileName}\"}}", $"{{\"imageUrl\":\"/uploads/users/{otherUserId:N}/keep.jpg\"}}"]); await cleanup.DeleteAsync(userId, references, CancellationToken.None); Assert.False(Directory.Exists(userDirectory)); Assert.False(File.Exists(reportPath)); Assert.False(File.Exists(chatImagePath)); Assert.True(File.Exists(otherUserPath)); Assert.True(File.Exists(outsidePath)); } finally { if (Directory.Exists(root)) Directory.Delete(root, recursive: true); if (File.Exists(outsidePath)) File.Delete(outsidePath); } } [Fact] public async Task UserService_CleansFilesBeforeDeletingDatabaseData() { var calls = new List(); var references = new AccountFileReferences(["/uploads/reports/report.pdf"], []); var repository = new FakeUserRepository(references, calls); var cleanup = new FakeAccountFileCleanup(calls); var service = new UserService(repository, cleanup); await service.DeleteAccountAsync(Guid.NewGuid(), CancellationToken.None); Assert.Equal(["references", "files", "database"], calls); Assert.Same(references, cleanup.ReceivedReferences); } [Fact] public async Task UserService_DoesNotDeleteDatabaseWhenFileCleanupFails() { var calls = new List(); var repository = new FakeUserRepository(new AccountFileReferences([], []), calls); var service = new UserService(repository, new FailingAccountFileCleanup(calls)); await Assert.ThrowsAsync(() => service.DeleteAccountAsync(Guid.NewGuid(), CancellationToken.None)); Assert.Equal(["references", "files"], calls); } private sealed class FakeUserRepository( AccountFileReferences references, List calls) : IUserRepository { public Task GetAsync(Guid userId, CancellationToken ct) => Task.FromResult(null); public Task GetAccountFileReferencesAsync(Guid userId, CancellationToken ct) { calls.Add("references"); return Task.FromResult(references); } public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask; public Task DeleteAccountDataAsync(Guid userId, CancellationToken ct) { calls.Add("database"); return Task.CompletedTask; } } private sealed class FakeAccountFileCleanup(List calls) : IAccountFileCleanup { public AccountFileReferences? ReceivedReferences { get; private set; } public Task DeleteAsync(Guid userId, AccountFileReferences references, CancellationToken ct) { calls.Add("files"); ReceivedReferences = references; return Task.CompletedTask; } } private sealed class FailingAccountFileCleanup(List calls) : IAccountFileCleanup { public Task DeleteAsync(Guid userId, AccountFileReferences references, CancellationToken ct) { calls.Add("files"); throw new IOException("file is locked"); } } }