Reference

Trax.Effect.Data.Testing

Package Trax.Effect.Data.TestingNamespace Trax.Effect.Data.TestingSource DataLayerGuards.cs, DomainDataLayerGuardFixture.cs, OwnerScopeCensusOptions.cs, TraxInvariants.cs

The data-layer guard package: checkers for the shape of domain data contexts, their EF models and their owner scoping, an NUnit fixture that runs them, and TraxInvariants, which checks a Trax database left consistent after a test. It depends on Trax.Core.Testing, Trax.Effect.Data, Roslyn and the Npgsql EF Core provider.

Signatures

namespace Trax.Effect.Data.Testing;
 
public static class DataLayerGuards
{
    public static GuardResult DomainContextsDeriveBase(ArchitectureGuardOptions options, string baseTypeName = "DomainDataContext", IReadOnlySet<string>? knownExceptions = null);
    public static GuardResult CompanionInterfaces(ArchitectureGuardOptions options, string baseTypeName = "DomainDataContext");
    public static GuardResult OneSchemaPerContext(IReadOnlyList<Type> contextTypes);
    public static GuardResult NoPendingModelChanges(IReadOnlyList<Type> migrationContextTypes);
    public static GuardResult OwnerScopeCompleteness(IReadOnlyModel model, OwnerScopeCensusOptions options);
    public static GuardResult OwnerScopeFilterBypasses(ArchitectureGuardOptions scan, IReadOnlyList<IReadOnlyModel> models, OwnerScopeCensusOptions options);
}
 
[TestFixture]
public abstract class DomainDataLayerGuardFixture
{
    protected abstract ArchitectureGuardOptions Options { get; }
    protected virtual IReadOnlyList<Type> DomainContexts { get; }
    protected virtual IReadOnlyList<Type> MigrationContexts { get; }
    protected virtual IReadOnlyList<IReadOnlyModel> OwnerScopedModels { get; }
    protected virtual OwnerScopeCensusOptions? OwnerScope { get; }
    protected virtual bool ScanForOwnerScopeFilterBypasses { get; }
    protected virtual string BaseTypeName { get; }
 
    [Test] public void Domain_contexts_derive_the_shared_base();
    [Test] public void Domain_contexts_have_companion_interfaces();
    [Test] public void Each_domain_context_owns_a_distinct_schema();
    [Test] public void Each_migration_context_matches_its_snapshot();
    [Test] public void Every_per_user_entity_is_scoped_to_its_owner();
    [Test] public void Query_code_does_not_switch_an_owner_scope_filter_off();
}
 
public sealed record OwnerScopeCensusOptions
{
    public required Type OwnerType { get; init; }
    public required Type PrincipalAccessorType { get; init; }
    public IReadOnlySet<string> OwnerIdProperties { get; init; }
    public IReadOnlyDictionary<Type, string> NavigationScoped { get; init; }
    public IReadOnlyDictionary<Type, string> Exemptions { get; init; }
    public IReadOnlyDictionary<Type, string> Gated { get; init; }
    public IReadOnlyDictionary<string, string> FilterBypassAllowlist { get; init; }
}
namespace Trax.Effect.Data.Testing;
 
public static class TraxInvariants
{
    public static Task<IReadOnlyList<TraxInvariantViolation>> FindViolationsAsync(string connectionString, CancellationToken cancellationToken = default);
    public static Task<IReadOnlyList<TraxInvariantViolation>> FindViolationsAsync(string connectionString, IEnumerable<InvokingState> invokingStates, CancellationToken cancellationToken = default);
    public static Task<IReadOnlyList<TraxInvariantViolation>> FindViolationsAsync(DbConnection connection, IEnumerable<InvokingState> invokingStates, CancellationToken cancellationToken = default);
    public static Task<IReadOnlyList<TraxInvariantViolation>> FindViolationsAsync(string connectionString, IEnumerable<InvokingState> invokingStates, IEnumerable<TimedState> timedStates, TimeSpan? maxDraftLifetime = default, int? maxDraftsPerUser = default, IEnumerable<KeptState>? keptStates = null, CancellationToken cancellationToken = default);
    public static Task<IReadOnlyList<TraxInvariantViolation>> FindViolationsAsync(DbConnection connection, IEnumerable<InvokingState> invokingStates, IEnumerable<TimedState> timedStates, TimeSpan? maxDraftLifetime = default, int? maxDraftsPerUser = default, IEnumerable<KeptState>? keptStates = null, CancellationToken cancellationToken = default);
    public static Task AssertConsistentAsync(string connectionString, CancellationToken cancellationToken = default);
    public static Task AssertConsistentAsync(string connectionString, IEnumerable<InvokingState> invokingStates, CancellationToken cancellationToken = default);
    public static string Describe(IReadOnlyCollection<TraxInvariantViolation> violations);
    public static bool IsExempt(Type fixture, string? methodName);
}
 
public sealed record TraxInvariantViolation(string Invariant, string Table, string Id, string Detail);
public sealed record InvokingState(string Machine, string State);
public sealed record TimedState(string Machine, string State);
public sealed record KeptState(string Machine, string State);
 
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = true)]
public sealed class LeavesStuckRunsAttribute : Attribute
{
    public LeavesStuckRunsAttribute(string reason);
    public string Reason { get; }
}

Members

DataLayerGuards

CheckerChecksReads
DomainContextsDeriveBaseEvery *DbContext class under the source scan roots derives baseTypeName (DomainDataContext<TSelf> by default). Each class is judged by its own base list, parsed with Roslyn.Source
CompanionInterfacesEvery context deriving the base has a companion I{Name} interface in the same directorySource
OneSchemaPerContextEach context owns a distinct, non-null default schema; the model is built offline against PostgreSQLModel
NoPendingModelChangesEach migration-based context's EF model matches its latest migration snapshot, so MigrateAsync does not trip PendingModelChangesWarning at startup. Built offline.Model
OwnerScopeCompletenessEvery entity holding per-user data has a row filter that reads the principal accessor, and a [TraxQueryModel] among them carries a bare [TraxAuthorize] unless it is in GatedModel
OwnerScopeFilterBypassesNo IgnoreQueryFilters() call, and no named-filter disable naming an owner-scope filter, on a per-user set or on a set the scan cannot name, outside FilterBypassAllowlistSource

The source checkers report a scan root that does not exist as an offender. How the census decides what is per-user, and what neither owner-scope check can see, is in Architecture Guards.

DomainDataLayerGuardFixture

TestRunsPasses when not configured
Domain_contexts_derive_the_shared_baseDomainContextsDeriveBase(Options, BaseTypeName)No: fails on an offender, then on having inspected no context
Domain_contexts_have_companion_interfacesCompanionInterfaces(Options, BaseTypeName)No: fails on an offender, then on having inspected no context
Each_domain_context_owns_a_distinct_schemaOneSchemaPerContext(DomainContexts)Yes, with DomainContexts empty
Each_migration_context_matches_its_snapshotNoPendingModelChanges(MigrationContexts)Yes, with MigrationContexts empty
Every_per_user_entity_is_scoped_to_its_ownerOwnerScopeCompleteness over each of OwnerScopedModelsYes, with OwnerScopedModels empty. Set, without OwnerScope, it throws InvalidOperationException.
Query_code_does_not_switch_an_owner_scope_filter_offOwnerScopeFilterBypasses(Options, OwnerScopedModels, OwnerScope)Yes, with OwnerScopedModels empty or ScanForOwnerScopeFilterBypasses false
PropertyDefaultDescription
Options(abstract)Scan roots and allowlists
DomainContextsemptyThe domain context types
MigrationContextsemptyThe contexts that use EF migrations; not those bootstrapped with EnsureSchemaCreatedAsync
OwnerScopedModelsemptyOne EF model per context holding per-user data, built from a context instance
OwnerScopenullThe census options; required when OwnerScopedModels is set
ScanForOwnerScopeFilterBypassestrueWhether the bypass scan runs
BaseTypeName"DomainDataContext"The base the source guards look for

OwnerScopeCensusOptions

PropertyDefaultDescription
OwnerType(required)The entity rows belong to, such as a user profile. A foreign key to it marks the dependent as per-user.
PrincipalAccessorType(required)The type a row filter reads the current principal through. A filter referencing it is an owner-scope filter.
OwnerIdPropertiesemptyScalar properties holding an owner id without a modelled foreign key, matched by name
NavigationScopedemptyEntities that reach their owner only through a navigation, each with the reason
ExemptionsemptyPer-user entities left out, each with a reason. A blank reason, or an entity the census would not flag, is reported.
GatedemptyPer-user query models deliberately gated by a role or policy, each with the reason. The gate is added to the filter, never used in its place.
FilterBypassAllowlistemptyRepo-relative source files allowed to switch an owner-scope filter off, each with the reason

TraxInvariants

Checks a Trax Postgres or SQLite database once every host a test started has stopped.

MemberDescription
FindViolationsAsync(connectionString)Every violation in a Postgres database, ordered by check, then by id
FindViolationsAsync(connectionString, invokingStates)The same, and checks that each instance of a machine named in invokingStates holds an invoke token exactly when it is in one of those states
FindViolationsAsync(connection, invokingStates)The same checks over an open connection, Postgres or SQLite; the caller keeps owning it. Any other connection throws NotSupportedException.
FindViolationsAsync(..., timedStates, maxDraftLifetime, maxDraftsPerUser, keptStates)Also the revision, owner and row-id checks, the timer checks for the machines whose timed states are passed, the lifetime check with maxDraftLifetime, and the per-user cap with maxDraftsPerUser
AssertConsistentAsync(connectionString), AssertConsistentAsync(connectionString, invokingStates)Throws InvalidOperationException listing every violation
Describe(violations)That failure message: one line per violation, and how to mark a test that leaves the state on purpose
IsExempt(fixture, methodName)True when the test method, its fixture or a base class of the fixture carries [LeavesStuckRuns]
ConstantValueFails on
RunInProgressrun-in-progressA trax.metadata row still in_progress
EffectClaimInFlighteffect-claim-in-flightA trax.effect_claim row with no receipt
DispatchedWithoutRundispatched-without-runA trax.work_queue row dispatched whose run does not exist
CheckpointOfCompletedRuncheckpoint-of-completed-runA trax.checkpoint row of a run that completed
ResumeFromMissingRunresume-from-missing-runA trax.work_queue or trax.metadata row whose resume_from names no run
InvokeTokenWithoutRuninvoke-token-without-runA trax.snapshot_draft row whose invoke token names no run the instance queued and no child it spawned
InvokingStateWithoutTokeninvoking-state-without-tokenA trax.snapshot_draft row in an invoking state with no invoke token, unless invoke_stranded_state is its state
InvokeTokenOutsideInvokingStateinvoke-token-outside-invoking-stateA trax.snapshot_draft row holding an invoke token in a state that invokes nothing
RevisionBelowOnerevision-below-oneA trax.snapshot_draft row whose revision is below 1
UserDraftWithoutOwneruser-draft-without-ownerA user's draft whose user_key is empty or whitespace
DuplicateRowIdduplicate-row-idTwo trax.snapshot_draft rows sharing one row id
DueAtOutsideTimedStatedue-at-outside-timed-stateA due time in a state with no timer
TimedStateWithoutDueAttimed-state-without-due-atA state with a timer and no due time
DueAtOfARemovedTimerdue-at-of-a-removed-timerA due time on a machine whose timed states declare none
DraftOlderThanMaxLifetimedraft-older-than-max-lifetimeA user's draft older than maxDraftLifetime, unless it is in one of keptStates
DraftsAboveCapdrafts-above-capA user holding more drafts than maxDraftsPerUser, reported once per user

TraxInvariantViolation(Invariant, Table, Id, Detail) is one violation; its ToString() is [invariant] table id: detail. On SQLite a violation names its table without the trax. schema. InvokingState, TimedState and KeptState name a machine and a state as trax.snapshot_draft stores them. [LeavesStuckRuns("why")] marks a test method or fixture that leaves one of these states on purpose; Reason is the why.

Examples

using Trax.Effect.Data.Testing;
 
var violations = await TraxInvariants.FindViolationsAsync(
    connectionString,
    [new InvokingState("Checkout", "AwaitingPayment")]
);
 
if (violations.Count > 0)
    throw new InvalidOperationException(TraxInvariants.Describe(violations));

The fixture, subclassed in an NUnit test project:

[TestFixture]
public sealed class DataLayerGuards : DomainDataLayerGuardFixture
{
    protected override ArchitectureGuardOptions Options => new() { SourceScanRoots = ["libs", "apps"] };
    protected override IReadOnlyList<Type> DomainContexts => [typeof(CatalogDbContext), typeof(LendingDbContext)];
    protected override IReadOnlyList<Type> MigrationContexts => [typeof(CatalogDbContext)];
}

See also