using System; using System.Collections.Generic; using AutopilotMonitor.Functions.DataAccess.TableStorage; using AutopilotMonitor.Shared.Models; using Azure.Data.Tables; using Xunit; namespace AutopilotMonitor.Functions.Tests; /// /// Round-trip pins for the SessionAnnotations Store/Map pair (memory: /// feedback_table_storage_serialization — every new field in /// MUST be exercised in Store+Map so silent drops surface here), plus the RowKey and /// OData-filter builders. /// public class TableSessionAnnotationRepositoryTests { private const string TenantId = "11111111-1111-1111-1111-111111111111"; private const string SessionId = "22222222-2222-2222-2222-222222222222"; private static SessionAnnotation FullAnnotation() => new() { TenantId = TenantId, SessionId = SessionId, Lane = AnnotationLanes.TenantAdmin, Verdict = AnnotationVerdicts.RootCauseConfirmed, Note = "Root cause matches what we saw on the device.", AuthorUpn = "alice@contoso.com", AuthorDisplayName = "Alice Admin", CreatedByUpn = "bob@contoso.com", CreatedAtUtc = new DateTime(2026, 8, 1, 10, 30, 0, DateTimeKind.Utc), UpdatedAtUtc = new DateTime(2026, 8, 11, 9, 15, 0, DateTimeKind.Utc), RuleIds = new List { "ANALYZE-ESP-001", "ANALYZE-CORR-003" }, }; [Fact] public void Roundtrip_all_fields_survive_store_and_map() { var original = FullAnnotation(); var mapped = TableSessionAnnotationRepository.MapAnnotation( TableSessionAnnotationRepository.StoreAnnotation(original)); Assert.Equal(original.TenantId, mapped.TenantId); Assert.Equal(original.SessionId, mapped.SessionId); Assert.Equal(original.Lane, mapped.Lane); Assert.Equal(original.Verdict, mapped.Verdict); Assert.Equal(original.Note, mapped.Note); Assert.Equal(original.AuthorUpn, mapped.AuthorUpn); Assert.Equal(original.AuthorDisplayName, mapped.AuthorDisplayName); Assert.Equal(original.CreatedByUpn, mapped.CreatedByUpn); Assert.Equal(original.CreatedAtUtc, mapped.CreatedAtUtc); Assert.Equal(original.UpdatedAtUtc, mapped.UpdatedAtUtc); Assert.Equal(original.RuleIds, mapped.RuleIds); } [Fact] public void Roundtrip_null_verdict_and_note_stay_null() { var original = FullAnnotation(); original.Verdict = null; original.Note = null; original.RuleIds = new List(); var mapped = TableSessionAnnotationRepository.MapAnnotation( TableSessionAnnotationRepository.StoreAnnotation(original)); Assert.Null(mapped.Verdict); Assert.Null(mapped.Note); Assert.Empty(mapped.RuleIds); } [Fact] public void Store_uses_tenant_pk_and_sessionId_lane_rk() { var entity = TableSessionAnnotationRepository.StoreAnnotation(FullAnnotation()); Assert.Equal(TenantId, entity.PartitionKey); Assert.Equal($"{SessionId}_{AnnotationLanes.TenantAdmin}", entity.RowKey); } [Theory] [InlineData("not json at all")] [InlineData("{\"truncated\":")] [InlineData("")] [InlineData(null)] public void Map_corrupt_or_missing_ruleIds_degrades_to_empty_list(string? corruptJson) { var entity = TableSessionAnnotationRepository.StoreAnnotation(FullAnnotation()); entity["RuleIdsJson"] = corruptJson; var mapped = TableSessionAnnotationRepository.MapAnnotation(entity); Assert.Empty(mapped.RuleIds); } [Fact] public void Map_tolerates_minimal_legacy_entity() { // A row carrying only PK/RK (e.g. hand-repaired) must map without throwing. var entity = new TableEntity(TenantId, $"{SessionId}_{AnnotationLanes.Operator}"); var mapped = TableSessionAnnotationRepository.MapAnnotation(entity); Assert.Equal(TenantId, mapped.TenantId); Assert.Equal(string.Empty, mapped.SessionId); Assert.Null(mapped.Verdict); Assert.Null(mapped.Note); Assert.Empty(mapped.RuleIds); } // ── query filter builder ──────────────────────────────────────────────── // ── free-text note search (client-side, like ruleId) ───────────────────── [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] public void MatchesQuery_blank_query_matches_every_row(string? query) { var a = new SessionAnnotation { Note = null, Verdict = null }; Assert.True(TableSessionAnnotationRepository.MatchesQuery(a, query)); } [Theory] [InlineData("wifi")] [InlineData("WIFI")] [InlineData(" WiFi switch ")] public void MatchesQuery_matches_note_substring_case_insensitively(string query) { var a = new SessionAnnotation { Note = "Nice demo: WiFi switch at minute 3, then LAN.", Verdict = null }; Assert.True(TableSessionAnnotationRepository.MatchesQuery(a, query)); } [Fact] public void MatchesQuery_matches_verdict_too() { var a = new SessionAnnotation { Note = null, Verdict = "analysis_wrong" }; Assert.True(TableSessionAnnotationRepository.MatchesQuery(a, "wrong")); } [Fact] public void MatchesQuery_ignores_lane_author_and_rule_ids() { var a = new SessionAnnotation { Note = "plain note", Verdict = null, Lane = "tenantadmin", AuthorDisplayName = "Leia Organa", RuleIds = new List { "ANALYZE-ESP-001" }, }; Assert.False(TableSessionAnnotationRepository.MatchesQuery(a, "tenantadmin")); Assert.False(TableSessionAnnotationRepository.MatchesQuery(a, "Leia")); Assert.False(TableSessionAnnotationRepository.MatchesQuery(a, "ESP-001")); } [Fact] public void MatchesQuery_no_hit_returns_false() { var a = new SessionAnnotation { Note = "ethernet only", Verdict = "root_cause_confirmed" }; Assert.False(TableSessionAnnotationRepository.MatchesQuery(a, "wifi")); } [Fact] public void BuildQueryFilter_no_filters_returns_null() { Assert.Null(TableSessionAnnotationRepository.BuildQueryFilter(null, null, null, null, null)); } [Fact] public void BuildQueryFilter_combines_all_clauses_with_and() { var filter = TableSessionAnnotationRepository.BuildQueryFilter( TenantId, AnnotationLanes.GlobalAdmin, AnnotationVerdicts.AnalysisWrong, new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc), new DateTime(2026, 8, 11, 0, 0, 0, DateTimeKind.Utc)); Assert.Equal( $"PartitionKey eq '{TenantId}'" + " and Lane eq 'globaladmin'" + " and Verdict eq 'analysis_wrong'" + " and UpdatedAtUtc ge datetime'2026-08-01T00:00:00Z'" + " and UpdatedAtUtc lt datetime'2026-08-11T00:00:00Z'", filter); } [Fact] public void BuildQueryFilter_escapes_single_quotes() { var filter = TableSessionAnnotationRepository.BuildQueryFilter("ten'ant", null, null, null, null); Assert.Equal("PartitionKey eq 'ten''ant'", filter); } [Fact] public void BuildQueryFilter_excludes_globaladmin_lane_for_tenant_scope() { var filter = TableSessionAnnotationRepository.BuildQueryFilter( TenantId, null, null, null, null, excludeGlobalAdminLane: true); Assert.Equal($"PartitionKey eq '{TenantId}' and Lane ne 'globaladmin'", filter); } [Fact] public void BuildQueryFilter_explicit_globaladmin_lane_with_exclusion_contradicts_to_empty() { // A tenant caller explicitly asking for lane=globaladmin gets a self-contradicting // filter (eq + ne on the same value) — structurally an empty result, never a leak. var filter = TableSessionAnnotationRepository.BuildQueryFilter( TenantId, AnnotationLanes.GlobalAdmin, null, null, null, excludeGlobalAdminLane: true); Assert.Contains("Lane eq 'globaladmin'", filter); Assert.Contains("Lane ne 'globaladmin'", filter); } }