mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
84a0424789
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>
166 lines
6.3 KiB
C#
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);
|
|
}
|
|
}
|