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
| Checker | Checks | Reads |
|---|---|---|
DomainContextsDeriveBase | Every *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 |
CompanionInterfaces | Every context deriving the base has a companion I{Name} interface in the same directory | Source |
OneSchemaPerContext | Each context owns a distinct, non-null default schema; the model is built offline against PostgreSQL | Model |
NoPendingModelChanges | Each migration-based context's EF model matches its latest migration snapshot, so MigrateAsync does not trip PendingModelChangesWarning at startup. Built offline. | Model |
OwnerScopeCompleteness | Every 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 Gated | Model |
OwnerScopeFilterBypasses | No 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 FilterBypassAllowlist | Source |
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
| Test | Runs | Passes when not configured |
|---|---|---|
Domain_contexts_derive_the_shared_base | DomainContextsDeriveBase(Options, BaseTypeName) | No: fails on an offender, then on having inspected no context |
Domain_contexts_have_companion_interfaces | CompanionInterfaces(Options, BaseTypeName) | No: fails on an offender, then on having inspected no context |
Each_domain_context_owns_a_distinct_schema | OneSchemaPerContext(DomainContexts) | Yes, with DomainContexts empty |
Each_migration_context_matches_its_snapshot | NoPendingModelChanges(MigrationContexts) | Yes, with MigrationContexts empty |
Every_per_user_entity_is_scoped_to_its_owner | OwnerScopeCompleteness over each of OwnerScopedModels | Yes, with OwnerScopedModels empty. Set, without OwnerScope, it throws InvalidOperationException. |
Query_code_does_not_switch_an_owner_scope_filter_off | OwnerScopeFilterBypasses(Options, OwnerScopedModels, OwnerScope) | Yes, with OwnerScopedModels empty or ScanForOwnerScopeFilterBypasses false |
| Property | Default | Description |
|---|---|---|
Options | (abstract) | Scan roots and allowlists |
DomainContexts | empty | The domain context types |
MigrationContexts | empty | The contexts that use EF migrations; not those bootstrapped with EnsureSchemaCreatedAsync |
OwnerScopedModels | empty | One EF model per context holding per-user data, built from a context instance |
OwnerScope | null | The census options; required when OwnerScopedModels is set |
ScanForOwnerScopeFilterBypasses | true | Whether the bypass scan runs |
BaseTypeName | "DomainDataContext" | The base the source guards look for |
OwnerScopeCensusOptions
| Property | Default | Description |
|---|---|---|
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. |
OwnerIdProperties | empty | Scalar properties holding an owner id without a modelled foreign key, matched by name |
NavigationScoped | empty | Entities that reach their owner only through a navigation, each with the reason |
Exemptions | empty | Per-user entities left out, each with a reason. A blank reason, or an entity the census would not flag, is reported. |
Gated | empty | Per-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. |
FilterBypassAllowlist | empty | Repo-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.
| Member | Description |
|---|---|
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] |
| Constant | Value | Fails on |
|---|---|---|
RunInProgress | run-in-progress | A trax.metadata row still in_progress |
EffectClaimInFlight | effect-claim-in-flight | A trax.effect_claim row with no receipt |
DispatchedWithoutRun | dispatched-without-run | A trax.work_queue row dispatched whose run does not exist |
CheckpointOfCompletedRun | checkpoint-of-completed-run | A trax.checkpoint row of a run that completed |
ResumeFromMissingRun | resume-from-missing-run | A trax.work_queue or trax.metadata row whose resume_from names no run |
InvokeTokenWithoutRun | invoke-token-without-run | A trax.snapshot_draft row whose invoke token names no run the instance queued and no child it spawned |
InvokingStateWithoutToken | invoking-state-without-token | A trax.snapshot_draft row in an invoking state with no invoke token, unless invoke_stranded_state is its state |
InvokeTokenOutsideInvokingState | invoke-token-outside-invoking-state | A trax.snapshot_draft row holding an invoke token in a state that invokes nothing |
RevisionBelowOne | revision-below-one | A trax.snapshot_draft row whose revision is below 1 |
UserDraftWithoutOwner | user-draft-without-owner | A user's draft whose user_key is empty or whitespace |
DuplicateRowId | duplicate-row-id | Two trax.snapshot_draft rows sharing one row id |
DueAtOutsideTimedState | due-at-outside-timed-state | A due time in a state with no timer |
TimedStateWithoutDueAt | timed-state-without-due-at | A state with a timer and no due time |
DueAtOfARemovedTimer | due-at-of-a-removed-timer | A due time on a machine whose timed states declare none |
DraftOlderThanMaxLifetime | draft-older-than-max-lifetime | A user's draft older than maxDraftLifetime, unless it is in one of keptStates |
DraftsAboveCap | drafts-above-cap | A 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
- Architecture Guards, the owner-scope census and the patterns the guards enforce
- Trax.Core.Testing
- Domain data contexts