using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using AutopilotMonitor.Functions.DataAccess.TableStorage; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Shared.Models; using Azure; using Azure.Data.Tables; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; namespace AutopilotMonitor.Functions.Tests; /// /// TableConfigBackupRepository: Store↔Map roundtrip (project rule "table-serialization"), /// reverse-ticks RowKey ordering (newest-first), and prune-beyond-keep semantics. /// public class ConfigBackupRepositoryTests { private const string TenantId = "11111111-1111-1111-1111-111111111111"; private sealed class Harness { public TableConfigBackupRepository Sut { get; } public Mock Table { get; } = new(); public List Upserts { get; } = new(); public List<(string Pk, string Rk)> Deletes { get; } = new(); public Harness() { Table.Setup(c => c.UpsertEntityAsync( It.IsAny(), It.IsAny(), It.IsAny())) .Returns((e, _, _) => { Upserts.Add(e); return Task.FromResult(Mock.Of()); }); Table.Setup(c => c.DeleteEntityAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns((pk, rk, _, _) => { Deletes.Add((pk, rk)); return Task.FromResult(Mock.Of()); }); var serviceClient = new Mock(); serviceClient.Setup(c => c.GetTableClient(It.IsAny())).Returns(Table.Object); Sut = new TableConfigBackupRepository( new TableStorageService(serviceClient.Object, NullLogger.Instance), NullLogger.Instance); } public void SetupQuery(params TableEntity[] rows) { var page = Page.FromValues(rows, continuationToken: null, Mock.Of()); Table.Setup(c => c.QueryAsync( It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) .Returns(AsyncPageable.FromPages(new[] { page })); } } private static ConfigBackupEntry Entry(string rowKey = "0001_abc") => new() { PartitionKey = TenantId, RowKey = rowKey, TenantId = TenantId, EntityJson = "{\"DomainName\":\"contoso.com\"}", ChangedBy = "ga@operator.example", Source = "mcp-patch", Reason = "retention bump", DiffJson = "{\"DataRetentionDays\":\"30 \\u2192 90\"}", BackupTakenAt = new DateTime(2026, 8, 3, 12, 0, 0, DateTimeKind.Utc), }; [Fact] public async Task Roundtrip_StoreAndMap_PreservesEveryField() { var harness = new Harness(); var entry = Entry(); await harness.Sut.UpsertAsync(entry); var stored = Assert.Single(harness.Upserts); // Feed the stored entity back through the read path (TryGetAsync → Map). harness.Table.Setup(c => c.GetEntityAsync( It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) .ReturnsAsync(Response.FromValue(stored, Mock.Of())); var mapped = await harness.Sut.TryGetAsync(TenantId, entry.RowKey); Assert.NotNull(mapped); Assert.Equal(entry.PartitionKey, mapped!.PartitionKey); Assert.Equal(entry.RowKey, mapped.RowKey); Assert.Equal(entry.TenantId, mapped.TenantId); Assert.Equal(entry.EntityJson, mapped.EntityJson); Assert.Equal(entry.ChangedBy, mapped.ChangedBy); Assert.Equal(entry.Source, mapped.Source); Assert.Equal(entry.Reason, mapped.Reason); Assert.Equal(entry.DiffJson, mapped.DiffJson); Assert.Equal(entry.BackupTakenAt, mapped.BackupTakenAt); } [Fact] public void BuildRowKey_LaterTimestamps_SortFirst() { var earlier = TableConfigBackupRepository.BuildRowKey(new DateTime(2026, 8, 3, 12, 0, 0, DateTimeKind.Utc)); var later = TableConfigBackupRepository.BuildRowKey(new DateTime(2026, 8, 3, 12, 0, 1, DateTimeKind.Utc)); // Reverse ticks: the LATER backup gets the LEXICOGRAPHICALLY SMALLER RowKey, so // Azure's RowKey-ascending partition scan returns newest-first. Assert.True(string.CompareOrdinal(later, earlier) < 0); } [Fact] public void BuildRowKey_SameTick_DistinctViaGuidSuffix() { var at = new DateTime(2026, 8, 3, 12, 0, 0, DateTimeKind.Utc); Assert.NotEqual( TableConfigBackupRepository.BuildRowKey(at), TableConfigBackupRepository.BuildRowKey(at)); } [Fact] public async Task Prune_DeletesEverythingBeyondKeep_InScanOrder() { var harness = new Harness(); // Scan order = RowKey ascending = newest first (reverse ticks). keep=2 must // delete rows 3 and 4, never the two newest. harness.SetupQuery( new TableEntity(TenantId, "0001_newest"), new TableEntity(TenantId, "0002_second"), new TableEntity(TenantId, "0003_old"), new TableEntity(TenantId, "0004_oldest")); var deleted = await harness.Sut.PruneAsync(TenantId, keep: 2); Assert.Equal(2, deleted); Assert.Equal(new[] { "0003_old", "0004_oldest" }, harness.Deletes.Select(d => d.Rk).ToArray()); } [Fact] public async Task Prune_WithinKeep_DeletesNothing() { var harness = new Harness(); harness.SetupQuery( new TableEntity(TenantId, "0001_newest"), new TableEntity(TenantId, "0002_second")); var deleted = await harness.Sut.PruneAsync(TenantId, keep: 2); Assert.Equal(0, deleted); Assert.Empty(harness.Deletes); } [Fact] public async Task ListByPartition_CapsAtMax() { var harness = new Harness(); harness.SetupQuery( new TableEntity(TenantId, "0001"), new TableEntity(TenantId, "0002"), new TableEntity(TenantId, "0003")); var listed = await harness.Sut.ListByPartitionAsync(TenantId, max: 2); Assert.Equal(2, listed.Count); Assert.Equal("0001", listed[0].RowKey); } }