Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions.Tests/IndexReconcileEnvelopeSerializationTests.cs
T
Oliver Kieselbach c3ee0cad55 feat(backend): M5.d.2 index-reconcile queue producer + feature flag
Adds the async fan-out producer path from Plan §2.8 / §M5.d so primary
Signals + DecisionTransitions rows can be projected onto their index
tables without blocking the hot ingest loop.

Components:
- Constants.QueueNames.TelemetryIndexReconcile (azure-queue-name-safe,
  all lowercase + hyphens).
- IndexReconcileEnvelope (Shared DTO, flat shape with nullable fields
  per SourceKind="Signal"/"DecisionTransition"). Avoids polymorphic
  Newtonsoft deserialization — see M5.c.3 finding.
- IndexReconcileEnvelopeFactory pure static helper (FromSignal /
  FromDecisionTransition / BuildBatch) — state-less projection.
- IIndexReconcileProducer (Shared interface) contract: no-op when
  the feature flag is off; never rethrows underlying-queue failures.
- AzureQueueIndexReconcileProducer (Functions) — Base64-encoded JSON
  messages, lazy CreateIfNotExistsAsync guarded by Interlocked flag,
  Managed-Identity-or-connection-string construction mirroring
  BlobStorageService / TableStorageService.
- IngestTelemetryFunction ctor gains IIndexReconcileProducer; after
  primary Signals + DecisionTransitions commit it builds envelopes
  and enqueues them. Event path is untouched (events don't feed the
  index tables per §2.8).
- Program.cs registers the producer as a singleton.

Feature gate (AdminConfiguration.EnableIndexDualWrite, default false):
- New bool field in AdminConfiguration.
- TableConfigRepository Store + Map extension (per
  feedback_table_storage_serialization).
- Producer checks config each call via AdminConfigurationService's
  5-min memory cache. Flag off → primary behaviour is bit-exact.

Fault tolerance: queue-side exceptions (network, throttling, missing
queue before first CreateIfNotExists) are logged per envelope and
swallowed. The primary row is the source of truth; the 2h reconcile
timer (M5.d.4) is the safety-net recovery path.

Tests (+11 in Functions):
- 5 IndexReconcileEnvelopeFactoryTests: Signal/DecisionTransition
  projection, dead-end preservation, batch ordering, null/empty.
- 3 IndexReconcileEnvelopeSerializationTests: Newtonsoft round-trip
  (Signal, full DecisionTransition, minimal-envelope tolerance).
- 3 AdminConfigurationEnableIndexDualWriteTests: default-false on
  new config + CreateDefault, persists when set.

Deferred to later sub-milestones:
- M5.d.3: queue-triggered consumer that maps envelopes to 0–3 index
  rows using M5.d.1 TableIndexRepository.
- M5.d.4: 2h reconcile timer + release-gate dual-write verification
  tests, then flip EnableIndexDualWrite=true in prod admin config.

Full Functions suite: 672 green (prev 661 + 11 new). Full solution
build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 11:48:27 +02:00

103 lines
4.4 KiB
C#

using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// JSON round-trip tests for <see cref="IndexReconcileEnvelope"/> (Plan §2.8, §M5.d).
/// The producer (M5.d.2) serializes with Newtonsoft; the queue-triggered consumer
/// (M5.d.3) must deserialize the same shape back — verified here so the contract
/// doesn't drift silently as fields are added.
/// </summary>
public class IndexReconcileEnvelopeSerializationTests
{
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
private static readonly DateTime Occurred =
new(2026, 4, 21, 14, 7, 33, DateTimeKind.Utc);
[Fact]
public void Signal_envelope_round_trips_through_newtonsoft()
{
var original = new IndexReconcileEnvelope
{
EnvelopeVersion = "1",
SourceKind = "Signal",
TenantId = TenantId,
SessionId = SessionId,
OccurredAtUtc = Occurred,
SessionSignalOrdinal = 42,
SignalKind = "EspTerminalFailure",
SourceOrigin = "EspAndHelloTrackerAdapter",
};
var json = JsonConvert.SerializeObject(original);
var restored = JsonConvert.DeserializeObject<IndexReconcileEnvelope>(json);
Assert.NotNull(restored);
Assert.Equal("1", restored!.EnvelopeVersion);
Assert.Equal("Signal", restored.SourceKind);
Assert.Equal(TenantId, restored.TenantId);
Assert.Equal(SessionId, restored.SessionId);
Assert.Equal(Occurred, restored.OccurredAtUtc.ToUniversalTime());
Assert.Equal(42L, restored.SessionSignalOrdinal);
Assert.Equal("EspTerminalFailure", restored.SignalKind);
Assert.Equal("EspAndHelloTrackerAdapter", restored.SourceOrigin);
Assert.Null(restored.StepIndex);
Assert.Null(restored.ClassifierVerdictId);
}
[Fact]
public void DecisionTransition_envelope_round_trips_all_discriminators()
{
var original = new IndexReconcileEnvelope
{
EnvelopeVersion = "1",
SourceKind = "DecisionTransition",
TenantId = TenantId,
SessionId = SessionId,
OccurredAtUtc = Occurred,
StepIndex = 7,
FromStage = "EspInProgress",
ToStage = "WhiteGloveSealed",
Taken = true,
IsTerminal = true,
DeadEndReason = null,
ClassifierVerdictId = "whiteglove-sealing",
ClassifierHypothesisLevel = "Strong",
};
var json = JsonConvert.SerializeObject(original);
var restored = JsonConvert.DeserializeObject<IndexReconcileEnvelope>(json);
Assert.NotNull(restored);
Assert.Equal("DecisionTransition", restored!.SourceKind);
Assert.Equal(7, restored.StepIndex);
Assert.Equal("EspInProgress", restored.FromStage);
Assert.Equal("WhiteGloveSealed", restored.ToStage);
Assert.Equal(true, restored.Taken);
Assert.Equal(true, restored.IsTerminal);
Assert.Null(restored.DeadEndReason);
Assert.Equal("whiteglove-sealing", restored.ClassifierVerdictId);
Assert.Equal("Strong", restored.ClassifierHypothesisLevel);
Assert.Null(restored.SessionSignalOrdinal);
}
[Fact]
public void Deserialization_tolerates_missing_optional_fields()
{
// A minimal envelope from a consumer-compatible-but-older writer should still parse.
const string minimal =
"{\"envelopeVersion\":\"1\",\"sourceKind\":\"Signal\",\"tenantId\":\"t\",\"sessionId\":\"s\",\"occurredAtUtc\":\"2026-04-21T14:07:33Z\"}";
var restored = JsonConvert.DeserializeObject<IndexReconcileEnvelope>(minimal);
Assert.NotNull(restored);
Assert.Equal("Signal", restored!.SourceKind);
Assert.Null(restored.SessionSignalOrdinal);
Assert.Null(restored.SignalKind);
Assert.Null(restored.StepIndex);
}
}