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.DataAccess; using Azure; using Azure.Data.Tables; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; namespace AutopilotMonitor.Functions.Tests; /// /// Round-trip + shape-guard coverage for the two partitions in the new Feedback /// table. Per memory feedback_table_storage_serialization: every new field in /// MUST be exercised here so silent drops surface in tests, /// not in production. /// /// The table is intentionally NOT in any offboarding-wipe list — these tests are also the /// canonical "this storage survives offboarding" contract; if anyone later wires Feedback /// into TenantOffboardingHandler.DiscriminatorTables, the In-App-survives-offboarding /// guarantee breaks and the user-feedback Reports tab loses history. /// /// public class TableFeedbackRepositoryTests { private const string TenantId = "88888888-8888-8888-8888-888888888888"; private const string Upn = "alice@contoso.invalid"; private const string HistoryRowKey = "20260519091523123_88888888-8888-8888-8888-888888888888"; [Fact] public async Task InApp_RoundTrips_AllFields() { var harness = new Harness(); var entry = new FeedbackEntry { Upn = Upn, TenantId = TenantId, DisplayName = "Alice (Contoso)", Rating = 4, Comment = "Mostly good — desktop arrival detection is a bit slow.", Dismissed = false, Submitted = true, InteractedAt = new DateTime(2026, 5, 19, 9, 15, 23, DateTimeKind.Utc), }; await harness.Sut.SaveInAppFeedbackAsync(entry); var fetched = await harness.Sut.GetInAppFeedbackAsync(Upn); Assert.NotNull(fetched); Assert.Equal(FeedbackEntryType.InApp, fetched!.Type); Assert.Equal(Upn, fetched.Upn); Assert.Equal(TenantId, fetched.TenantId); Assert.Equal("Alice (Contoso)", fetched.DisplayName); Assert.Equal(4, fetched.Rating); Assert.Equal("Mostly good — desktop arrival detection is a bit slow.", fetched.Comment); Assert.False(fetched.Dismissed); Assert.True(fetched.Submitted); Assert.Equal(entry.InteractedAt, fetched.InteractedAt); } [Fact] public async Task InApp_Save_LowercasesUpn_ForRowKey() { var harness = new Harness(); await harness.Sut.SaveInAppFeedbackAsync(new FeedbackEntry { Upn = "Alice@Contoso.Invalid", TenantId = TenantId, DisplayName = "Alice", Rating = 5, Submitted = true, InteractedAt = DateTime.UtcNow, }); // Get by lowercased upn must hit. Different-case lookup hits the same row because the // function-side endpoint forwards the auth-claim upn verbatim while storage // normalises — pin both halves of that contract here. var fetchedLower = await harness.Sut.GetInAppFeedbackAsync("alice@contoso.invalid"); var fetchedMixed = await harness.Sut.GetInAppFeedbackAsync("Alice@Contoso.Invalid"); Assert.NotNull(fetchedLower); Assert.NotNull(fetchedMixed); } [Fact] public async Task InApp_Dismissed_RoundTrips_NullRatingAndNullComment() { var harness = new Harness(); await harness.Sut.SaveInAppFeedbackAsync(new FeedbackEntry { Upn = Upn, TenantId = TenantId, DisplayName = "Alice", Rating = null, Comment = null, Dismissed = true, Submitted = false, InteractedAt = DateTime.UtcNow, }); var fetched = await harness.Sut.GetInAppFeedbackAsync(Upn); Assert.NotNull(fetched); Assert.Null(fetched!.Rating); Assert.Null(fetched.Comment); Assert.True(fetched.Dismissed); Assert.False(fetched.Submitted); } [Fact] public async Task InApp_Get_404_ReturnsNull() { var harness = new Harness(); Assert.Null(await harness.Sut.GetInAppFeedbackAsync(Upn)); } [Fact] public async Task Offboarding_RoundTrips_AllFields() { var harness = new Harness(); var entry = new FeedbackEntry { HistoryRowKey = HistoryRowKey, TenantId = TenantId, Upn = Upn, DisplayName = "Alice (Contoso)", DomainName = "contoso.invalid", Comment = "Pricing was too high for our use case.", InteractedAt = new DateTime(2026, 5, 19, 9, 20, 0, DateTimeKind.Utc), }; await harness.Sut.SaveOffboardingFeedbackAsync(entry); var fetched = await harness.Sut.GetOffboardingFeedbackAsync(HistoryRowKey); Assert.NotNull(fetched); Assert.Equal(FeedbackEntryType.Offboarding, fetched!.Type); Assert.Equal(HistoryRowKey, fetched.HistoryRowKey); Assert.Equal(TenantId, fetched.TenantId); Assert.Equal(Upn, fetched.Upn); Assert.Equal("contoso.invalid", fetched.DomainName); Assert.Equal("Pricing was too high for our use case.", fetched.Comment); Assert.Equal(entry.InteractedAt, fetched.InteractedAt); } [Fact] public async Task Offboarding_Save_RejectsMissingHistoryRowKey() { var harness = new Harness(); await Assert.ThrowsAsync(() => harness.Sut.SaveOffboardingFeedbackAsync(new FeedbackEntry { TenantId = TenantId, Upn = Upn, Comment = "no historyRowKey set", })); } [Fact] public async Task Offboarding_Get_RejectsEmptyHistoryRowKey() { var harness = new Harness(); await Assert.ThrowsAsync(() => harness.Sut.GetOffboardingFeedbackAsync(string.Empty)); } [Fact] public async Task Save_Stamps_TypeDiscriminator_RegardlessOfCallerSetting() { // Defensive: callers may forget to set Type; the repo MUST stamp the partition's own // discriminator on every save so a misrouted Save can never poison the other partition. var harness = new Harness(); await harness.Sut.SaveInAppFeedbackAsync(new FeedbackEntry { Type = "WRONG", // caller error Upn = Upn, TenantId = TenantId, DisplayName = "Alice", Rating = 5, Submitted = true, InteractedAt = DateTime.UtcNow, }); var fetched = await harness.Sut.GetInAppFeedbackAsync(Upn); Assert.Equal(FeedbackEntryType.InApp, fetched!.Type); await harness.Sut.SaveOffboardingFeedbackAsync(new FeedbackEntry { Type = "WRONG", HistoryRowKey = HistoryRowKey, TenantId = TenantId, Upn = Upn, Comment = "x", InteractedAt = DateTime.UtcNow, }); var offb = await harness.Sut.GetOffboardingFeedbackAsync(HistoryRowKey); Assert.Equal(FeedbackEntryType.Offboarding, offb!.Type); } [Fact] public async Task GetAllAsync_ReturnsBothPartitions_WithDiscriminator() { // Reports page reads via GetAllAsync and filters client-side by Type. Confirm the // mixed-partition return shape works end-to-end. var harness = new Harness(); await harness.Sut.SaveInAppFeedbackAsync(new FeedbackEntry { Upn = "alice@contoso.invalid", TenantId = TenantId, DisplayName = "Alice", Rating = 5, Submitted = true, InteractedAt = DateTime.UtcNow, }); await harness.Sut.SaveInAppFeedbackAsync(new FeedbackEntry { Upn = "bob@fabrikam.invalid", TenantId = "99999999-9999-9999-9999-999999999999", DisplayName = "Bob", Rating = null, Dismissed = true, InteractedAt = DateTime.UtcNow, }); await harness.Sut.SaveOffboardingFeedbackAsync(new FeedbackEntry { HistoryRowKey = HistoryRowKey, TenantId = TenantId, Upn = "alice@contoso.invalid", DisplayName = "Alice", DomainName = "contoso.invalid", Comment = "Pricing too high.", InteractedAt = DateTime.UtcNow, }); var all = await harness.Sut.GetAllAsync(); var inApp = all.Where(e => e.Type == FeedbackEntryType.InApp).ToList(); var offb = all.Where(e => e.Type == FeedbackEntryType.Offboarding).ToList(); Assert.Equal(2, inApp.Count); Assert.Single(offb); Assert.Equal("contoso.invalid", offb[0].DomainName); Assert.Equal(HistoryRowKey, offb[0].HistoryRowKey); } // ── Harness — minimal TableClient mock with a (PK,RK) → TableEntity store ── private sealed class Harness { public TableFeedbackRepository Sut { get; } private readonly Dictionary<(string Pk, string Rk), TableEntity> _store = new(); public Harness() { var mockTableClient = new Mock(); mockTableClient .Setup(c => c.UpsertEntityAsync( It.IsAny(), It.IsAny(), It.IsAny())) .Returns((e, _, _) => { _store[(e.PartitionKey, e.RowKey)] = e; return Task.FromResult(new Mock().Object); }); mockTableClient .Setup(c => c.GetEntityAsync( It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) .Returns, CancellationToken>((pk, rk, _, _) => { if (!_store.TryGetValue((pk, rk), out var entity)) throw new RequestFailedException(404, "NotFound", "ResourceNotFound", null); return Task.FromResult(Response.FromValue(entity, new Mock().Object)); }); // QueryAsync — no filter, returns all stored entities. mockTableClient .Setup(c => c.QueryAsync( It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) .Returns(() => AsyncPageable.FromPages(new[] { Page.FromValues(_store.Values.ToList(), null, new Mock().Object), })); var mockServiceClient = new Mock(); mockServiceClient.Setup(s => s.GetTableClient(It.IsAny())) .Returns(mockTableClient.Object); var storage = new TableStorageService( mockServiceClient.Object, NullLogger.Instance); Sut = new TableFeedbackRepository(storage, NullLogger.Instance); } } }