Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions.Tests/TableSessionAnnotationRepositoryTests.cs
T
Oliver Kieselbach 84a0424789 annotations: tenant-scoped overview page listing judged sessions
New GET /api/session-annotations (MemberRead + QueryParam, tenant always
the middleware-validated target) backing the portal /annotations page in
the Monitoring sidebar group (deliberately no new Insights group). For
callers without global scope the repository excludes the globaladmin
lane server-side (Lane ne clause), so hidden rows never consume page
budget and an explicit lane filter self-contradicts to empty. Rows
deep-link to the session annotation section. Verdict pill styling moved
to the shared logic module.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 16:13:04 +02:00

166 lines
6.3 KiB
C#

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;
/// <summary>
/// Round-trip pins for the SessionAnnotations Store/Map pair (memory:
/// feedback_table_storage_serialization — every new field in <see cref="SessionAnnotation"/>
/// MUST be exercised in Store+Map so silent drops surface here), plus the RowKey and
/// OData-filter builders.
/// </summary>
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<string> { "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<string>();
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 ────────────────────────────────────────────────
[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);
}
}