mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
40d88ef1e0
- Tenant Admins add the Push channel; Telegram and e-mail stay Global-Admin-only - One Push channel per scope on the tenant and ops paths; the dispatcher fans Push out once per send - The channel also gates session-watch completions and the device test; paired devices stay listed while it is off - Members whose role comes from an Entra app role pair and receive like table members; a paused device resumes at the owner's next sign-in - SLA and failure-streak alerts name the tenant; the id moves to a Tenant ID fact - The legacy single webhook accepts URL webhook providers only - Portal: Conditional Access hint in the channel editor, neutral role texts - Landing: push section with the Notify me when done animation and the hero announcement; SignalR is "live updates" - Privacy, about and FAQ describe push notifications Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
533 lines
21 KiB
C#
533 lines
21 KiB
C#
using System.Reflection;
|
|
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
|
using AutopilotMonitor.Shared;
|
|
using AutopilotMonitor.Shared.DataAccess;
|
|
using Azure;
|
|
using Azure.Data.Tables;
|
|
|
|
namespace AutopilotMonitor.Functions.Tests.Push;
|
|
|
|
/// <summary>
|
|
/// Store → Map round trip of <see cref="TablePushRepository"/> for the four models it projects
|
|
/// (<see cref="PushDevice"/>, <see cref="PushPairingGrant"/>, <see cref="PushOwner"/>,
|
|
/// <see cref="PushSessionWatch"/>). Every row is a credential, binds one or decides who receives
|
|
/// pushes, so a column dropped on either side is a silently unpaired phone, a reusable code or a
|
|
/// lost role. The comparisons run by reflection over the model's public properties: a property
|
|
/// added later is covered without touching this file, and the fixture guard fails until the fixture
|
|
/// carries a distinct non-default value for it.
|
|
/// </summary>
|
|
public class TablePushRepositoryMappingTests
|
|
{
|
|
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
|
private const string OwnerHomeTenantId = "22222222-2222-2222-2222-222222222222";
|
|
private const string SessionId = "33333333-3333-3333-3333-333333333333";
|
|
private const string DeviceId = "dddddddd-0000-0000-0000-000000000001";
|
|
private const string OwnerObjectId = "aaaaaaaa-0000-0000-0000-000000000001";
|
|
private static readonly DateTime Anchor = new(2026, 10, 7, 8, 30, 0, DateTimeKind.Utc);
|
|
|
|
// Documented key mappings: model property → entity key, where the value does not live in a column of its own name.
|
|
private static readonly Dictionary<string, string> DeviceKeys = new() { ["Scope"] = "PartitionKey", ["DeviceId"] = "RowKey" };
|
|
private static readonly Dictionary<string, string> GrantKeys = new() { ["CodeHash"] = "RowKey" };
|
|
private static readonly Dictionary<string, string> OwnerKeys = new() { ["HomeTenantId"] = "PartitionKey", ["ObjectId"] = "RowKey" };
|
|
private static readonly Dictionary<string, string> WatchKeys = new() { ["TenantId"] = "PartitionKey" };
|
|
|
|
// ── Devices ─────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void Device_round_trip_preserves_every_property()
|
|
{
|
|
var device = FullDevice();
|
|
|
|
var mapped = TablePushRepository.MapDevice(TablePushRepository.StoreDevice(device));
|
|
|
|
AssertSameProperties(device, mapped);
|
|
}
|
|
|
|
[Fact]
|
|
public void Device_fixture_sets_every_property_to_a_distinct_non_default_value()
|
|
=> AssertFixtureIsDistinctAndNonDefault(FullDevice());
|
|
|
|
[Fact]
|
|
public void Device_store_writes_every_property_as_a_column_or_key()
|
|
{
|
|
var device = FullDevice();
|
|
|
|
var entity = TablePushRepository.StoreDevice(device);
|
|
|
|
Assert.Equal(device.Scope, entity.PartitionKey);
|
|
Assert.Equal(device.DeviceId, entity.RowKey);
|
|
AssertEveryPropertyIsStored<PushDevice>(entity, DeviceKeys);
|
|
}
|
|
|
|
[Fact]
|
|
public void Device_store_labels_every_date_utc_without_moving_it()
|
|
{
|
|
var input = WithUnspecifiedKinds(FullDevice());
|
|
|
|
var entity = TablePushRepository.StoreDevice(input);
|
|
var mapped = TablePushRepository.MapDevice(entity);
|
|
|
|
AssertDatesStoredAsUtc(input, entity, mapped);
|
|
}
|
|
|
|
[Fact]
|
|
public void Device_status_reason_cleared_to_empty_string_maps_to_null()
|
|
{
|
|
// A Merge cannot clear a column, so MutateDeviceAsync writes "" for a cleared reason.
|
|
var entity = TablePushRepository.StoreDevice(FullDevice());
|
|
entity["StatusReason"] = string.Empty;
|
|
|
|
Assert.Null(TablePushRepository.MapDevice(entity).StatusReason);
|
|
}
|
|
|
|
[Fact]
|
|
public void Device_without_status_reason_column_maps_to_null()
|
|
{
|
|
var entity = new TableEntity(TenantId, DeviceId);
|
|
|
|
Assert.Null(TablePushRepository.MapDevice(entity).StatusReason);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("gone")]
|
|
[InlineData("vapid_key_mismatch")]
|
|
[InlineData("invalid_subscription")]
|
|
[InlineData("owner_inactive")]
|
|
[InlineData("delivery_failures")]
|
|
public void Device_set_status_reason_maps_verbatim(string reason)
|
|
{
|
|
var entity = new TableEntity(TenantId, DeviceId) { ["StatusReason"] = reason };
|
|
|
|
Assert.Equal(reason, TablePushRepository.MapDevice(entity).StatusReason);
|
|
}
|
|
|
|
[Fact]
|
|
public void Device_absent_nullable_columns_map_to_null()
|
|
{
|
|
var mapped = TablePushRepository.MapDevice(new TableEntity(TenantId, DeviceId));
|
|
|
|
var nullable = PublicProperties<PushDevice>().Where(p => Nullable.GetUnderlyingType(p.PropertyType) != null).ToList();
|
|
Assert.NotEmpty(nullable);
|
|
var populated = nullable.Where(p => p.GetValue(mapped) != null).Select(p => p.Name).ToList();
|
|
Assert.Empty(populated);
|
|
}
|
|
|
|
[Fact]
|
|
public void Device_without_optional_values_round_trips_the_nulls()
|
|
{
|
|
// Edm.DateTime cannot hold default(DateTime): a null stays null through Store (the Utc helper) and Map.
|
|
var device = FullDevice();
|
|
foreach (var p in PublicProperties<PushDevice>().Where(p => Nullable.GetUnderlyingType(p.PropertyType) != null))
|
|
p.SetValue(device, null);
|
|
device.StatusReason = null;
|
|
|
|
var mapped = TablePushRepository.MapDevice(TablePushRepository.StoreDevice(device));
|
|
|
|
AssertSameProperties(device, mapped);
|
|
}
|
|
|
|
[Fact]
|
|
public void Device_row_without_columns_maps_to_a_pending_device_with_model_defaults()
|
|
{
|
|
var mapped = TablePushRepository.MapDevice(new TableEntity(TenantId, DeviceId));
|
|
|
|
Assert.Equal(TenantId, mapped.Scope);
|
|
Assert.Equal(DeviceId, mapped.DeviceId);
|
|
// A row without a Status column is Pending — it must never become Active by omission.
|
|
Assert.Equal(Constants.Push.DeviceStatus.Pending, mapped.Status);
|
|
Assert.Equal(new PushDevice().Kind, mapped.Kind);
|
|
Assert.Equal(new PushDevice().Platform, mapped.Platform);
|
|
Assert.Equal(string.Empty, mapped.OwnerUpn);
|
|
Assert.Equal(DateTime.MinValue, mapped.PairedUtc);
|
|
Assert.Equal(0, mapped.ConsecutiveFailures);
|
|
Assert.Equal(0, mapped.WindowCount);
|
|
Assert.Equal(0, mapped.SuppressedCount);
|
|
}
|
|
|
|
// ── Grants ──────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void Grant_round_trip_preserves_every_property_except_the_etag()
|
|
{
|
|
var grant = FullGrant();
|
|
|
|
var entity = TablePushRepository.StoreGrant(grant);
|
|
var mapped = TablePushRepository.MapGrant(entity);
|
|
|
|
// ETag is the storage concurrency token: Map takes it from the entity, which a freshly built
|
|
// TableEntity does not carry, so it cannot equal the input (see Grant_etag_comes_from_the_entity…).
|
|
AssertSameProperties(grant, mapped, nameof(PushPairingGrant.ETag));
|
|
Assert.Equal(entity.ETag.ToString(), mapped.ETag);
|
|
}
|
|
|
|
[Fact]
|
|
public void Grant_fixture_sets_every_property_to_a_distinct_non_default_value()
|
|
=> AssertFixtureIsDistinctAndNonDefault(FullGrant());
|
|
|
|
[Fact]
|
|
public void Grant_store_writes_every_property_as_a_column_or_key()
|
|
{
|
|
var grant = FullGrant();
|
|
|
|
var entity = TablePushRepository.StoreGrant(grant);
|
|
|
|
Assert.Equal("grant", entity.PartitionKey);
|
|
Assert.Equal(grant.CodeHash, entity.RowKey);
|
|
// ETag is never a column: TryUpdateGrantAsync sends it as the If-Match condition.
|
|
AssertEveryPropertyIsStored<PushPairingGrant>(entity, GrantKeys, nameof(PushPairingGrant.ETag));
|
|
Assert.False(entity.ContainsKey(nameof(PushPairingGrant.ETag)));
|
|
}
|
|
|
|
[Fact]
|
|
public void Grant_etag_comes_from_the_entity_not_from_the_stored_grant()
|
|
{
|
|
var grant = FullGrant(); // carries the ETag of an earlier read
|
|
var entity = TablePushRepository.StoreGrant(grant);
|
|
entity.ETag = new ETag("W/\"datetime'2026-10-07T08%3A31%3A00.0000000Z'\"");
|
|
|
|
var mapped = TablePushRepository.MapGrant(entity);
|
|
|
|
Assert.Equal(entity.ETag.ToString(), mapped.ETag);
|
|
Assert.NotEqual(grant.ETag, mapped.ETag);
|
|
}
|
|
|
|
[Fact]
|
|
public void Grant_without_redeem_round_trips_null_redeemed_and_device_id()
|
|
{
|
|
var grant = FullGrant();
|
|
grant.Status = Constants.Push.PairingStatus.Pending;
|
|
grant.RedeemedUtc = null;
|
|
grant.DeviceId = null;
|
|
|
|
var mapped = TablePushRepository.MapGrant(TablePushRepository.StoreGrant(grant));
|
|
|
|
Assert.Null(mapped.RedeemedUtc);
|
|
Assert.Null(mapped.DeviceId);
|
|
AssertSameProperties(grant, mapped, nameof(PushPairingGrant.ETag));
|
|
}
|
|
|
|
[Fact]
|
|
public void Grant_store_labels_every_date_utc_without_moving_it()
|
|
{
|
|
var input = WithUnspecifiedKinds(FullGrant());
|
|
|
|
var entity = TablePushRepository.StoreGrant(input);
|
|
var mapped = TablePushRepository.MapGrant(entity);
|
|
|
|
AssertDatesStoredAsUtc(input, entity, mapped);
|
|
}
|
|
|
|
// ── Owners ──────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void Owner_round_trip_preserves_every_property()
|
|
{
|
|
var owner = FullOwner();
|
|
|
|
var mapped = TablePushRepository.MapOwner(TablePushRepository.StoreOwner(owner));
|
|
|
|
AssertSameProperties(owner, mapped);
|
|
}
|
|
|
|
[Fact]
|
|
public void Owner_fixture_sets_every_property_to_a_distinct_non_default_value()
|
|
=> AssertFixtureIsDistinctAndNonDefault(FullOwner());
|
|
|
|
[Fact]
|
|
public void Owner_store_writes_every_property_as_a_column_or_key()
|
|
{
|
|
var owner = FullOwner();
|
|
|
|
var entity = TablePushRepository.StoreOwner(owner);
|
|
|
|
Assert.Equal(owner.HomeTenantId, entity.PartitionKey);
|
|
Assert.Equal(owner.ObjectId, entity.RowKey);
|
|
AssertEveryPropertyIsStored<PushOwner>(entity, OwnerKeys);
|
|
}
|
|
|
|
[Fact]
|
|
public void Owner_store_lower_cases_keys_and_upn_but_keeps_the_role_verbatim()
|
|
{
|
|
var owner = FullOwner();
|
|
owner.HomeTenantId = "22222222-AAAA-2222-2222-222222222222";
|
|
owner.ObjectId = "AAAAAAAA-0000-0000-0000-000000000001";
|
|
owner.Upn = "Owner@Contoso.invalid";
|
|
|
|
var mapped = TablePushRepository.MapOwner(TablePushRepository.StoreOwner(owner));
|
|
|
|
Assert.Equal("22222222-aaaa-2222-2222-222222222222", mapped.HomeTenantId);
|
|
Assert.Equal(OwnerObjectId, mapped.ObjectId);
|
|
Assert.Equal("owner@contoso.invalid", mapped.Upn);
|
|
Assert.Equal(Constants.TenantRoles.Operator, mapped.ClaimRole);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
public void Owner_without_a_claim_role_maps_to_null(string? stored)
|
|
{
|
|
var owner = FullOwner();
|
|
owner.ClaimRole = null;
|
|
var entity = TablePushRepository.StoreOwner(owner);
|
|
entity["ClaimRole"] = stored;
|
|
|
|
Assert.Null(TablePushRepository.MapOwner(entity).ClaimRole);
|
|
Assert.Null(TablePushRepository.MapOwner(new TableEntity(OwnerHomeTenantId, OwnerObjectId)).ClaimRole);
|
|
}
|
|
|
|
[Fact]
|
|
public void Owner_store_labels_the_sign_in_date_utc_without_moving_it()
|
|
{
|
|
var input = WithUnspecifiedKinds(FullOwner());
|
|
|
|
var entity = TablePushRepository.StoreOwner(input);
|
|
var mapped = TablePushRepository.MapOwner(entity);
|
|
|
|
AssertDatesStoredAsUtc(input, entity, mapped);
|
|
}
|
|
|
|
// ── Watches ─────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void Watch_round_trip_preserves_every_property()
|
|
{
|
|
// Lower-case inputs: TenantId, OwnerObjectId and OwnerUpn are lower-cased on store (dedicated test below).
|
|
var watch = FullWatch();
|
|
|
|
var mapped = TablePushRepository.MapWatch(TablePushRepository.StoreWatch(watch));
|
|
|
|
AssertSameProperties(watch, mapped);
|
|
}
|
|
|
|
[Fact]
|
|
public void Watch_fixture_sets_every_property_to_a_distinct_non_default_value()
|
|
=> AssertFixtureIsDistinctAndNonDefault(FullWatch());
|
|
|
|
[Fact]
|
|
public void Watch_store_writes_every_property_as_a_column_or_key()
|
|
{
|
|
var watch = FullWatch();
|
|
|
|
var entity = TablePushRepository.StoreWatch(watch);
|
|
|
|
Assert.Equal(watch.TenantId, entity.PartitionKey);
|
|
// SessionId and OwnerObjectId compose the RowKey (point read and per-session range scan) and
|
|
// are stored as columns as well, so Map never has to split the key.
|
|
Assert.Equal($"{watch.SessionId}_{watch.OwnerObjectId}", entity.RowKey);
|
|
AssertEveryPropertyIsStored<PushSessionWatch>(entity, WatchKeys);
|
|
}
|
|
|
|
[Fact]
|
|
public void Watch_store_lower_cases_tenant_owner_object_id_and_upn()
|
|
{
|
|
var watch = FullWatch();
|
|
watch.TenantId = "11111111-AAAA-1111-1111-111111111111";
|
|
watch.OwnerObjectId = "AAAAAAAA-0000-0000-0000-000000000001";
|
|
watch.OwnerUpn = "Watcher@Contoso.invalid";
|
|
|
|
var entity = TablePushRepository.StoreWatch(watch);
|
|
var mapped = TablePushRepository.MapWatch(entity);
|
|
|
|
Assert.Equal("11111111-aaaa-1111-1111-111111111111", entity.PartitionKey);
|
|
Assert.Equal($"{SessionId}_aaaaaaaa-0000-0000-0000-000000000001", entity.RowKey);
|
|
Assert.Equal("11111111-aaaa-1111-1111-111111111111", mapped.TenantId);
|
|
Assert.Equal("aaaaaaaa-0000-0000-0000-000000000001", mapped.OwnerObjectId);
|
|
Assert.Equal("watcher@contoso.invalid", mapped.OwnerUpn);
|
|
}
|
|
|
|
[Fact]
|
|
public void Watch_owner_scopes_list_survives_the_round_trip()
|
|
{
|
|
var watch = FullWatch();
|
|
watch.OwnerScopes = $"{Constants.Push.PlatformScope};{TenantId}";
|
|
|
|
var mapped = TablePushRepository.MapWatch(TablePushRepository.StoreWatch(watch));
|
|
|
|
Assert.Equal("platform;11111111-1111-1111-1111-111111111111", mapped.OwnerScopes);
|
|
}
|
|
|
|
[Fact]
|
|
public void Watch_store_labels_every_date_utc_without_moving_it()
|
|
{
|
|
var input = WithUnspecifiedKinds(FullWatch());
|
|
|
|
var entity = TablePushRepository.StoreWatch(input);
|
|
var mapped = TablePushRepository.MapWatch(entity);
|
|
|
|
AssertDatesStoredAsUtc(input, entity, mapped);
|
|
}
|
|
|
|
[Fact]
|
|
public void Watch_row_without_columns_maps_to_empty_scopes_and_min_value_dates()
|
|
{
|
|
var mapped = TablePushRepository.MapWatch(new TableEntity(TenantId, $"{SessionId}_{OwnerObjectId}"));
|
|
|
|
Assert.Equal(TenantId, mapped.TenantId);
|
|
// Legacy rows without OwnerScopes mean "the session tenant"; Map reads the columns, not the RowKey.
|
|
Assert.Equal(string.Empty, mapped.OwnerScopes);
|
|
Assert.Equal(string.Empty, mapped.SessionId);
|
|
Assert.Equal(string.Empty, mapped.OwnerObjectId);
|
|
Assert.Equal(string.Empty, mapped.OwnerUpn);
|
|
Assert.Equal(DateTime.MinValue, mapped.CreatedUtc);
|
|
Assert.Equal(DateTime.MinValue, mapped.ExpiresUtc);
|
|
}
|
|
|
|
// ── Fixtures: a distinct non-default value for every public property ────
|
|
|
|
private static PushDevice FullDevice() => new()
|
|
{
|
|
Scope = TenantId,
|
|
DeviceId = DeviceId,
|
|
OwnerUpn = "owner@contoso.invalid",
|
|
OwnerObjectId = OwnerObjectId,
|
|
OwnerHomeTenantId = OwnerHomeTenantId,
|
|
Kind = "apns",
|
|
Endpoint = "https://push.contoso.invalid/send/endpoint-0001",
|
|
P256dh = "p256dh-key-material-0001",
|
|
Auth = "auth-secret-0001",
|
|
DeviceSecretHash = "device-secret-hash-0001",
|
|
VapidKid = "KID00001",
|
|
Label = "My iPhone",
|
|
Platform = "ios-homescreen",
|
|
AppVersion = "1.2.3",
|
|
Status = Constants.Push.DeviceStatus.Paused,
|
|
StatusReason = "delivery_failures",
|
|
PairedUtc = Anchor,
|
|
ConfirmedUtc = Anchor.AddSeconds(1),
|
|
LastDeliveredUtc = Anchor.AddSeconds(2),
|
|
LastOpenedUtc = Anchor.AddSeconds(3),
|
|
StatusChangedUtc = Anchor.AddSeconds(4),
|
|
LastStatusCode = 410,
|
|
ConsecutiveFailures = 3,
|
|
WindowStartUtc = Anchor.AddSeconds(5),
|
|
WindowCount = 7,
|
|
SuppressedCount = 2,
|
|
};
|
|
|
|
private static PushPairingGrant FullGrant() => new()
|
|
{
|
|
CodeHash = "code-hash-base64url-0001",
|
|
TenantId = TenantId,
|
|
OwnerUpn = "admin@contoso.invalid",
|
|
OwnerObjectId = OwnerObjectId,
|
|
OwnerHomeTenantId = OwnerHomeTenantId,
|
|
Status = Constants.Push.PairingStatus.Redeemed,
|
|
CreatedUtc = Anchor,
|
|
ExpiresUtc = Anchor.AddMinutes(Constants.Push.PairingGrantMinutes),
|
|
RedeemedUtc = Anchor.AddSeconds(42),
|
|
DeviceId = DeviceId,
|
|
FailedAttempts = 2,
|
|
ETag = "W/\"datetime'2026-10-07T08%3A30%3A00.0000000Z'\"",
|
|
};
|
|
|
|
private static PushOwner FullOwner() => new()
|
|
{
|
|
HomeTenantId = OwnerHomeTenantId,
|
|
ObjectId = OwnerObjectId,
|
|
Upn = "owner@contoso.invalid",
|
|
LastSignInUtc = Anchor,
|
|
ClaimRole = Constants.TenantRoles.Operator,
|
|
};
|
|
|
|
private static PushSessionWatch FullWatch() => new()
|
|
{
|
|
TenantId = TenantId,
|
|
SessionId = SessionId,
|
|
OwnerObjectId = OwnerObjectId,
|
|
OwnerUpn = "watcher@contoso.invalid",
|
|
OwnerScopes = $"{Constants.Push.PlatformScope};{OwnerHomeTenantId}",
|
|
CreatedUtc = Anchor,
|
|
ExpiresUtc = Anchor.AddHours(6),
|
|
};
|
|
|
|
// ── Reflection helpers ──────────────────────────────────────────────────
|
|
|
|
private static PropertyInfo[] PublicProperties<T>()
|
|
=> typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);
|
|
|
|
private static IEnumerable<PropertyInfo> DateProperties<T>()
|
|
=> PublicProperties<T>().Where(p => p.PropertyType == typeof(DateTime) || p.PropertyType == typeof(DateTime?));
|
|
|
|
/// <summary>Every public property of <paramref name="expected"/> equals the one on <paramref name="actual"/>, except the named ones.</summary>
|
|
private static void AssertSameProperties<T>(T expected, T actual, params string[] except) where T : class
|
|
{
|
|
var mismatches = PublicProperties<T>()
|
|
.Where(p => !except.Contains(p.Name))
|
|
.Where(p => !Equals(p.GetValue(expected), p.GetValue(actual)))
|
|
.Select(p => $"{typeof(T).Name}.{p.Name}: expected '{p.GetValue(expected)}', got '{p.GetValue(actual)}'")
|
|
.ToList();
|
|
Assert.Empty(mismatches);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The round trip is only as strong as its input: a property left at its default would survive a
|
|
/// dropped column unnoticed, and two properties sharing a value would hide swapped columns.
|
|
/// </summary>
|
|
private static void AssertFixtureIsDistinctAndNonDefault<T>(T fixture) where T : class, new()
|
|
{
|
|
var blank = new T();
|
|
var atDefault = PublicProperties<T>()
|
|
.Where(p => Equals(p.GetValue(fixture), p.GetValue(blank)))
|
|
.Select(p => p.Name)
|
|
.ToList();
|
|
Assert.Empty(atDefault);
|
|
|
|
var duplicates = PublicProperties<T>()
|
|
.Select(p => p.GetValue(fixture))
|
|
.OfType<object>()
|
|
.GroupBy(v => v)
|
|
.Where(g => g.Count() > 1)
|
|
.Select(g => $"{g.Key}")
|
|
.ToList();
|
|
Assert.Empty(duplicates);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Every public property of <typeparamref name="T"/> lands in the entity: as the column of the same
|
|
/// name, or under its documented key mapping. A property added to the model without a Store line fails here.
|
|
/// </summary>
|
|
private static void AssertEveryPropertyIsStored<T>(TableEntity entity, IReadOnlyDictionary<string, string> keyMappings, params string[] notStored)
|
|
{
|
|
var missing = PublicProperties<T>()
|
|
.Where(p => !notStored.Contains(p.Name))
|
|
.Select(p => keyMappings.GetValueOrDefault(p.Name) ?? p.Name)
|
|
.Where(column => !entity.ContainsKey(column))
|
|
.ToList();
|
|
Assert.Empty(missing);
|
|
}
|
|
|
|
/// <summary>Relabels every date of the model as Unspecified without moving it — a value parsed from JSON without a zone designator.</summary>
|
|
private static T WithUnspecifiedKinds<T>(T model) where T : class
|
|
{
|
|
foreach (var p in DateProperties<T>())
|
|
{
|
|
if (p.GetValue(model) is DateTime dt)
|
|
p.SetValue(model, DateTime.SpecifyKind(dt, DateTimeKind.Unspecified));
|
|
}
|
|
return model;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Store labels every date Utc (Edm.DateTime is UTC-only; an Unspecified or Local value would be
|
|
/// serialised with a shift or without a zone): every DateTime column of the entity and every date
|
|
/// of the mapped model carries Kind Utc with the input's ticks untouched.
|
|
/// </summary>
|
|
private static void AssertDatesStoredAsUtc<T>(T input, TableEntity entity, T mapped) where T : class
|
|
{
|
|
var nonUtcColumns = entity.Keys
|
|
.Where(key => entity[key] is DateTime dt && dt.Kind != DateTimeKind.Utc)
|
|
.ToList();
|
|
Assert.Empty(nonUtcColumns);
|
|
|
|
var mismatches = new List<string>();
|
|
foreach (var p in DateProperties<T>())
|
|
{
|
|
if (p.GetValue(input) is not DateTime expected)
|
|
continue;
|
|
if (p.GetValue(mapped) is not DateTime actual || actual.Kind != DateTimeKind.Utc || actual.Ticks != expected.Ticks)
|
|
mismatches.Add($"{p.Name}: expected ticks {expected.Ticks} as Utc, got '{p.GetValue(mapped)}'");
|
|
}
|
|
Assert.Empty(mismatches);
|
|
}
|
|
}
|