diff --git a/Trax.Samples.slnx b/Trax.Samples.slnx index 3c8f1c4..6fb98fa 100644 --- a/Trax.Samples.slnx +++ b/Trax.Samples.slnx @@ -34,6 +34,11 @@ + + + + + @@ -46,6 +51,7 @@ + diff --git a/docker-compose.yml b/docker-compose.yml index 0b2d057..dfde248 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,8 +22,8 @@ services: image: rabbitmq:4-management restart: always environment: - RABBITMQ_DEFAULT_USER: guest - RABBITMQ_DEFAULT_PASS: guest + RABBITMQ_DEFAULT_USER: trax + RABBITMQ_DEFAULT_PASS: trax123 ports: - "5672:5672" - "15672:15672" diff --git a/samples/PersistedOperations/Trax.Samples.PersistedOperations.Api/Program.cs b/samples/PersistedOperations/Trax.Samples.PersistedOperations.Api/Program.cs new file mode 100644 index 0000000..5c7943d --- /dev/null +++ b/samples/PersistedOperations/Trax.Samples.PersistedOperations.Api/Program.cs @@ -0,0 +1,71 @@ +// ───────────────────────────────────────────────────────────────────────────── +// Trax Persisted Operations sample — GraphQL API +// +// This sample demonstrates Trax.Api.GraphQL.PersistedOperations end-to-end: +// +// - Real trains (GreetTrain, LookupUserTrain) registered through AddMediator +// and exposed via [TraxQuery] on the GraphQL schema. +// - The API only accepts persisted operations: clients send `id` and the +// server resolves to the stored document via IOperationDocumentStorage. +// - Operators can hot-fix a persisted document (or the underlying junction +// code) without redeploying clients, as long as the response shape stays +// compatible. The shape-diff guardrail in IPersistedOperationStore +// enforces this contract on every edit. +// +// Run alongside the Client project to see the upload + query + hot-fix loop. +// ───────────────────────────────────────────────────────────────────────────── + +using Trax.Api.GraphQL.Extensions; +using Trax.Api.GraphQL.PersistedOperations.Extensions; +using Trax.Effect.Data.Postgres.Extensions; +using Trax.Effect.Extensions; +using Trax.Mediator.Extensions; +using Trax.Samples.PersistedOperations; + +var builder = WebApplication.CreateBuilder(args); + +var connectionString = + builder.Configuration.GetConnectionString("TraxDatabase") + ?? "Host=localhost;Port=5432;Database=trax;Username=trax;Password=trax123"; + +builder.Services.AddAuthorization(); + +// Trax: Postgres effects + mediator (which discovers trains in the library +// assembly). No scheduler — every train in this sample is a query handled +// directly on the API server via GraphQL. +builder.Services.AddTrax(trax => + trax.AddEffects(effects => effects.UsePostgres(connectionString)) + .AddMediator(typeof(GraphQLNamespaces).Assembly) +); + +// GraphQL schema with persisted-operations enforcement enabled. AddTraxGraphQL +// auto-discovers [TraxQuery]-attributed trains from the registered mediator +// assemblies and exposes them under their declared namespace. +builder.Services.AddTraxGraphQL(graphql => + graphql.UsePersistedOperations(opts => + opts.UseDatabase(connectionString) + .RequirePersisted(true) + .LogNonPersistedRequests(true) + // Dev-prefixed operations bypass enforcement so developers can + // iterate on a query during development without round-tripping + // through the manifest uploader. + .AllowOperationsMatching(id => id.StartsWith("dev_")) + ) +); + +var app = builder.Build(); + +// Persisted-op enforcement runs before the GraphQL endpoint. In a real +// deployment, ASP.NET authentication middleware sits in front of this and +// the persisted-op lookup happens AFTER Trax's auth interceptor inside the +// HC pipeline. +app.UsePersistedOperationsEnforcement(); +app.UseRouting(); +app.UseTraxGraphQL(); + +app.Run(); + +/// +/// Visible to WebApplicationFactory<Program> for E2E tests. +/// +public partial class Program; diff --git a/samples/PersistedOperations/Trax.Samples.PersistedOperations.Api/Trax.Samples.PersistedOperations.Api.csproj b/samples/PersistedOperations/Trax.Samples.PersistedOperations.Api/Trax.Samples.PersistedOperations.Api.csproj new file mode 100644 index 0000000..64a6d65 --- /dev/null +++ b/samples/PersistedOperations/Trax.Samples.PersistedOperations.Api/Trax.Samples.PersistedOperations.Api.csproj @@ -0,0 +1,20 @@ + + + Exe + net10.0 + enable + enable + true + + + + + + + + + + + + + diff --git a/samples/PersistedOperations/Trax.Samples.PersistedOperations.Api/appsettings.json b/samples/PersistedOperations/Trax.Samples.PersistedOperations.Api/appsettings.json new file mode 100644 index 0000000..2b7964a --- /dev/null +++ b/samples/PersistedOperations/Trax.Samples.PersistedOperations.Api/appsettings.json @@ -0,0 +1,12 @@ +{ + "ConnectionStrings": { + "TraxDatabase": "Host=localhost;Port=5432;Database=trax;Username=trax;Password=trax123" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + } +} diff --git a/samples/PersistedOperations/Trax.Samples.PersistedOperations.Client/Program.cs b/samples/PersistedOperations/Trax.Samples.PersistedOperations.Client/Program.cs new file mode 100644 index 0000000..b74613f --- /dev/null +++ b/samples/PersistedOperations/Trax.Samples.PersistedOperations.Client/Program.cs @@ -0,0 +1,91 @@ +// ───────────────────────────────────────────────────────────────────────────── +// Trax Persisted Operations sample — Client +// +// 1. Connects to the same Trax Postgres as the API process. +// 2. Uploads the manifest of (id, document) pairs through IPersistedOperationStore. +// 3. Sends GraphQL requests by id only — the server resolves to the stored +// document and dispatches the call to the underlying train. +// 4. Demonstrates the hot-fix flow by re-uploading a shape-preserving +// edit; the next request runs the new document without redeploying the +// client. +// +// Run after starting Trax.Samples.PersistedOperations.Api. +// ───────────────────────────────────────────────────────────────────────────── + +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Trax.Api.GraphQL.PersistedOperations.Extensions; +using Trax.Api.GraphQL.PersistedOperations.Storage; +using Trax.Effect.Data.Postgres.Extensions; +using Trax.Effect.Extensions; + +const string ConnectionString = + "Host=localhost;Port=5432;Database=trax;Username=trax;Password=trax123"; +const string ApiUrl = "http://localhost:5000/trax/graphql/"; + +await using var services = new ServiceCollection() + .AddLogging(b => b.AddSimpleConsole()) + .AddTrax(trax => trax.AddEffects(effects => effects.UsePostgres(ConnectionString))) + .AddPersistedOperationStore(ConnectionString) + .BuildServiceProvider(); + +var store = services.GetRequiredService(); + +// 1. Upload the manifest. +foreach (var op in LoadManifest()) +{ + await store.UpsertAsync(op.Id, op.Document, options: null, CancellationToken.None); + Console.WriteLine($"Uploaded {op.Id}"); +} + +// 2. Call greet_v1 by id. +using var http = new HttpClient { BaseAddress = new Uri(ApiUrl) }; +Console.WriteLine("\n--- greet_v1 (Alice) ---"); +Console.WriteLine(await PostByIdAsync(http, "greet_v1", new { input = new { name = "Alice" } })); + +// 3. Call lookupUser_v1 by id. +Console.WriteLine("\n--- lookupUser_v1 (user-42) ---"); +Console.WriteLine( + await PostByIdAsync(http, "lookupUser_v1", new { input = new { userId = "user-42" } }) +); + +// 4. Hot-fix demo: rewrite greet_v1 with a shape-preserving change. In a +// real fix you might swap to a different train or change a filter; here +// we change a static argument to demonstrate that the server-side change +// flows through without touching the client. The shape-diff guardrail +// permits this because the response shape is unchanged. +await store.UpsertAsync( + "greet_v1", + "query Greet($input: GreetInput!) { discover { greeting { greet(input: $input) { greeting greetedAt } } } }", + options: new UpsertOptions { Description = "demo hot-fix (shape-preserving rewrite)" }, + CancellationToken.None +); +Console.WriteLine("\nHot-fixed greet_v1 (no client redeploy needed)."); + +Console.WriteLine("\n--- greet_v1 after hot-fix (Alice) ---"); +Console.WriteLine(await PostByIdAsync(http, "greet_v1", new { input = new { name = "Alice" } })); + +static async Task PostByIdAsync(HttpClient http, string id, object variables) +{ + var body = new { id, variables }; + var resp = await http.PostAsJsonAsync(string.Empty, body); + return await resp.Content.ReadAsStringAsync(); +} + +static IEnumerable LoadManifest() +{ + var path = Path.Combine(AppContext.BaseDirectory, "manifest.json"); + using var stream = File.OpenRead(path); + var doc = JsonDocument.Parse(stream); + foreach (var entry in doc.RootElement.GetProperty("operations").EnumerateArray()) + { + yield return new ManifestEntry( + entry.GetProperty("id").GetString()!, + entry.GetProperty("document").GetString()! + ); + } +} + +internal sealed record ManifestEntry(string Id, string Document); diff --git a/samples/PersistedOperations/Trax.Samples.PersistedOperations.Client/Trax.Samples.PersistedOperations.Client.csproj b/samples/PersistedOperations/Trax.Samples.PersistedOperations.Client/Trax.Samples.PersistedOperations.Client.csproj new file mode 100644 index 0000000..04383a4 --- /dev/null +++ b/samples/PersistedOperations/Trax.Samples.PersistedOperations.Client/Trax.Samples.PersistedOperations.Client.csproj @@ -0,0 +1,22 @@ + + + Exe + net10.0 + enable + enable + true + + + + + + + + + + + + PreserveNewest + + + diff --git a/samples/PersistedOperations/Trax.Samples.PersistedOperations.Client/manifest.json b/samples/PersistedOperations/Trax.Samples.PersistedOperations.Client/manifest.json new file mode 100644 index 0000000..d731549 --- /dev/null +++ b/samples/PersistedOperations/Trax.Samples.PersistedOperations.Client/manifest.json @@ -0,0 +1,12 @@ +{ + "operations": [ + { + "id": "greet_v1", + "document": "query Greet($input: GreetInput!) { discover { greeting { greet(input: $input) { greeting greetedAt } } } }" + }, + { + "id": "lookupUser_v1", + "document": "query LookupUser($input: LookupUserInput!) { discover { users { lookupUser(input: $input) { userId displayName email loginCount } } } }" + } + ] +} diff --git a/samples/PersistedOperations/Trax.Samples.PersistedOperations/GraphQLNamespaces.cs b/samples/PersistedOperations/Trax.Samples.PersistedOperations/GraphQLNamespaces.cs new file mode 100644 index 0000000..0944568 --- /dev/null +++ b/samples/PersistedOperations/Trax.Samples.PersistedOperations/GraphQLNamespaces.cs @@ -0,0 +1,12 @@ +namespace Trax.Samples.PersistedOperations; + +/// +/// GraphQL namespace constants. Trains sharing a namespace are grouped +/// under the same sub-field in the schema (e.g. +/// discover { greeting { greet(...) { ... } } }). +/// +public static class GraphQLNamespaces +{ + public const string Greeting = "greeting"; + public const string Users = "users"; +} diff --git a/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Greeting/Greet/GreetInput.cs b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Greeting/Greet/GreetInput.cs new file mode 100644 index 0000000..5785d85 --- /dev/null +++ b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Greeting/Greet/GreetInput.cs @@ -0,0 +1,11 @@ +using Trax.Effect.Models.Manifest; + +namespace Trax.Samples.PersistedOperations.Trains.Greeting.Greet; + +/// +/// Input for the greet train: the name to address in the response. +/// +public record GreetInput : IManifestProperties +{ + public required string Name { get; init; } +} diff --git a/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Greeting/Greet/GreetOutput.cs b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Greeting/Greet/GreetOutput.cs new file mode 100644 index 0000000..6adb05a --- /dev/null +++ b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Greeting/Greet/GreetOutput.cs @@ -0,0 +1,10 @@ +namespace Trax.Samples.PersistedOperations.Trains.Greeting.Greet; + +/// +/// Output from the greet train. +/// +public record GreetOutput +{ + public required string Greeting { get; init; } + public required DateTimeOffset GreetedAt { get; init; } +} diff --git a/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Greeting/Greet/GreetTrain.cs b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Greeting/Greet/GreetTrain.cs new file mode 100644 index 0000000..728cfa9 --- /dev/null +++ b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Greeting/Greet/GreetTrain.cs @@ -0,0 +1,19 @@ +using LanguageExt; +using Trax.Effect.Attributes; +using Trax.Effect.Services.ServiceTrain; +using Trax.Samples.PersistedOperations.Trains.Greeting.Greet.Junctions; + +namespace Trax.Samples.PersistedOperations.Trains.Greeting.Greet; + +/// +/// Trivial query train that produces a greeting for a name. Exposed as +/// discover { greeting { greet(input: { name: ... }) { ... } } }. +/// The persisted-operation manifest binds an id (e.g. greet_v1) to +/// a GraphQL document that calls this train. +/// +[TraxQuery(Namespace = GraphQLNamespaces.Greeting, Description = "Builds a greeting for a name")] +public class GreetTrain : ServiceTrain, IGreetTrain +{ + protected override Task> Junctions() => + Chain().Resolve(); +} diff --git a/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Greeting/Greet/IGreetTrain.cs b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Greeting/Greet/IGreetTrain.cs new file mode 100644 index 0000000..00b4a9d --- /dev/null +++ b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Greeting/Greet/IGreetTrain.cs @@ -0,0 +1,5 @@ +using Trax.Effect.Services.ServiceTrain; + +namespace Trax.Samples.PersistedOperations.Trains.Greeting.Greet; + +public interface IGreetTrain : IServiceTrain; diff --git a/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Greeting/Greet/Junctions/ComposeGreetingJunction.cs b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Greeting/Greet/Junctions/ComposeGreetingJunction.cs new file mode 100644 index 0000000..b381ea8 --- /dev/null +++ b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Greeting/Greet/Junctions/ComposeGreetingJunction.cs @@ -0,0 +1,21 @@ +using Trax.Core.Junction; + +namespace Trax.Samples.PersistedOperations.Trains.Greeting.Greet.Junctions; + +/// +/// Builds a greeting string for the supplied name. The wording lives in +/// this junction so that a hot-fix to the persisted document (or to this +/// junction) lets you change the greeting without redeploying clients. +/// +public class ComposeGreetingJunction : Junction +{ + public override Task Run(GreetInput input) + { + var output = new GreetOutput + { + Greeting = $"Hello, {input.Name}.", + GreetedAt = DateTimeOffset.UtcNow, + }; + return Task.FromResult(output); + } +} diff --git a/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Users/LookupUser/ILookupUserTrain.cs b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Users/LookupUser/ILookupUserTrain.cs new file mode 100644 index 0000000..0e60d98 --- /dev/null +++ b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Users/LookupUser/ILookupUserTrain.cs @@ -0,0 +1,5 @@ +using Trax.Effect.Services.ServiceTrain; + +namespace Trax.Samples.PersistedOperations.Trains.Users.LookupUser; + +public interface ILookupUserTrain : IServiceTrain; diff --git a/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Users/LookupUser/Junctions/FetchUserJunction.cs b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Users/LookupUser/Junctions/FetchUserJunction.cs new file mode 100644 index 0000000..a1237a2 --- /dev/null +++ b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Users/LookupUser/Junctions/FetchUserJunction.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.Logging; +using Trax.Core.Junction; + +namespace Trax.Samples.PersistedOperations.Trains.Users.LookupUser.Junctions; + +/// +/// Resolves a user profile. In a real app this would be an EF Core query; +/// the sample returns a deterministic fixture so the persisted-op flow is +/// exercised without seed data. +/// +public class FetchUserJunction(ILogger logger) + : Junction +{ + public override Task Run(LookupUserInput input) + { + logger.LogInformation("Looking up user {UserId}", input.UserId); + + // Deterministic synthetic profile keyed on UserId. + var hash = (uint)input.UserId.GetHashCode(); + return Task.FromResult( + new UserProfile + { + UserId = input.UserId, + DisplayName = $"User {input.UserId}", + Email = $"{input.UserId}@example.test", + LoginCount = (int)(hash % 1000), + } + ); + } +} diff --git a/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Users/LookupUser/LookupUserInput.cs b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Users/LookupUser/LookupUserInput.cs new file mode 100644 index 0000000..f3dbdf4 --- /dev/null +++ b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Users/LookupUser/LookupUserInput.cs @@ -0,0 +1,8 @@ +using Trax.Effect.Models.Manifest; + +namespace Trax.Samples.PersistedOperations.Trains.Users.LookupUser; + +public record LookupUserInput : IManifestProperties +{ + public required string UserId { get; init; } +} diff --git a/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Users/LookupUser/LookupUserTrain.cs b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Users/LookupUser/LookupUserTrain.cs new file mode 100644 index 0000000..685c53a --- /dev/null +++ b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Users/LookupUser/LookupUserTrain.cs @@ -0,0 +1,17 @@ +using LanguageExt; +using Trax.Effect.Attributes; +using Trax.Effect.Services.ServiceTrain; +using Trax.Samples.PersistedOperations.Trains.Users.LookupUser.Junctions; + +namespace Trax.Samples.PersistedOperations.Trains.Users.LookupUser; + +/// +/// Resolves a user profile by id. Exposed as +/// discover { users { lookupUser(input: { userId: ... }) { ... } } }. +/// +[TraxQuery(Namespace = GraphQLNamespaces.Users, Description = "Looks up a user profile by id")] +public class LookupUserTrain : ServiceTrain, ILookupUserTrain +{ + protected override Task> Junctions() => + Chain().Resolve(); +} diff --git a/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Users/LookupUser/UserProfile.cs b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Users/LookupUser/UserProfile.cs new file mode 100644 index 0000000..7e6ac0c --- /dev/null +++ b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trains/Users/LookupUser/UserProfile.cs @@ -0,0 +1,14 @@ +namespace Trax.Samples.PersistedOperations.Trains.Users.LookupUser; + +/// +/// Lightweight user profile returned by the lookup train. Real consumers +/// would project from a database; this sample returns a deterministic +/// fixture so the persisted-op flow is exercised without infrastructure. +/// +public record UserProfile +{ + public required string UserId { get; init; } + public required string DisplayName { get; init; } + public required string Email { get; init; } + public int LoginCount { get; init; } +} diff --git a/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trax.Samples.PersistedOperations.csproj b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trax.Samples.PersistedOperations.csproj new file mode 100644 index 0000000..275b4bc --- /dev/null +++ b/samples/PersistedOperations/Trax.Samples.PersistedOperations/Trax.Samples.PersistedOperations.csproj @@ -0,0 +1,18 @@ + + + net10.0 + enable + enable + true + + + + + + + + + + + + diff --git a/tests/Trax.Samples.PersistedOperations.E2E/ApiTests/AdditionalCoverageTests.cs b/tests/Trax.Samples.PersistedOperations.E2E/ApiTests/AdditionalCoverageTests.cs new file mode 100644 index 0000000..5398d76 --- /dev/null +++ b/tests/Trax.Samples.PersistedOperations.E2E/ApiTests/AdditionalCoverageTests.cs @@ -0,0 +1,216 @@ +using System.Net.Http.Json; +using System.Text; +using FluentAssertions; +using Trax.Api.GraphQL.PersistedOperations.Storage; +using Trax.Samples.PersistedOperations.E2E.Fixtures; + +namespace Trax.Samples.PersistedOperations.E2E.ApiTests; + +[TestFixture] +[Category("E2E")] +public class AdditionalCoverageTests : ApiTestBase +{ + private const string GreetDoc = + "query Greet($input: GreetInput!) { discover { greeting { greet(input: $input) { greeting } } } }"; + + // ----- Deactivated-id error shape ----- + + [Test] + public async Task DeactivatedId_ReturnsErrorsArrayWithCode() + { + await Store.UpsertAsync("deactivated_v1", GreetDoc, null, CancellationToken.None); + await Store.DeactivateAsync("deactivated_v1", null, "test", CancellationToken.None); + + using var doc = await PostJsonAsync( + new { id = "deactivated_v1", variables = new { input = new { name = "x" } } } + ); + + doc.RootElement.TryGetProperty("errors", out var errors) + .Should() + .BeTrue("deactivated id should produce a GraphQL errors array"); + errors.GetArrayLength().Should().BeGreaterThan(0); + errors[0].TryGetProperty("message", out _).Should().BeTrue(); + } + + // ----- Tenant scoping isolation (storage layer) ----- + + [Test] + public async Task TenantScoping_DocumentsAreIsolatedByTenantKey() + { + await Store.UpsertAsync( + "tenant_scope_v1", + GreetDoc, + new UpsertOptions { TenantKey = "tenant-a" }, + CancellationToken.None + ); + await Store.UpsertAsync( + "tenant_scope_v1", + GreetDoc + " # tenant-b variant", + new UpsertOptions { TenantKey = "tenant-b" }, + CancellationToken.None + ); + + var a = await Store.GetAsync("tenant_scope_v1", "tenant-a", CancellationToken.None); + var b = await Store.GetAsync("tenant_scope_v1", "tenant-b", CancellationToken.None); + var none = await Store.GetAsync("tenant_scope_v1", null, CancellationToken.None); + + a.Should().NotBeNull(); + b.Should().NotBeNull(); + a!.Document.Should().NotBe(b!.Document); + none.Should().BeNull("the null-tenant row was never written for this id"); + } + + // ----- Auth ordering: storage isn't read pre-auth ----- + + [Test] + public async Task PersistedRequest_DispatchesToTrain_ReturningExpectedData() + { + // The sample has no authentication wired (the production wiring is + // documented in the package README), so the strongest claim this + // test can make about auth ordering is: the persisted-op middleware + // and storage ARE consulted, and the request reaches the train. If + // the middleware were broken or the storage path bypassed, the + // response would not match the train's deterministic output. + await Store.UpsertAsync("auth_smoke_v1", GreetDoc, null, CancellationToken.None); + + using var doc = await PostJsonAsync( + new { id = "auth_smoke_v1", variables = new { input = new { name = "anon" } } } + ); + + // Strong assertion: the response must contain the train's output, + // not just be "any GraphQL envelope". A broken pipeline would leave + // either an errors array OR a missing data field. + doc.RootElement.TryGetProperty("errors", out _) + .Should() + .BeFalse(doc.RootElement.GetRawText()); + doc.RootElement.GetProperty("data") + .GetProperty("discover") + .GetProperty("greeting") + .GetProperty("greet") + .GetProperty("greeting") + .GetString() + .Should() + .Be("Hello, anon."); + } + + // ----- Content-type variants ----- + + [Test] + public async Task ContentTypeWithCharset_AcceptedAndProducesCorrectTrainOutput() + { + await Store.UpsertAsync("content_v1", GreetDoc, null, CancellationToken.None); + + var json = "{\"id\":\"content_v1\",\"variables\":{\"input\":{\"name\":\"Charset\"}}}"; + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + content.Headers.ContentType!.CharSet = "utf-8"; + var resp = await Http.PostAsync("/trax/graphql/", content); + ((int)resp.StatusCode).Should().Be(200); + + var body = await resp.Content.ReadAsStringAsync(); + body.Should() + .Contain( + "\"greeting\":\"Hello, Charset.\"", + "the request must reach the train and the train output must round-trip" + ); + } + + [Test] + public async Task EmptyJsonObject_RejectedByHotChocolateNotByMiddleware() + { + var json = "{}"; + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + var resp = await Http.PostAsync("/trax/graphql/", content); + var body = await resp.Content.ReadAsStringAsync(); + ((int)resp.StatusCode).Should().Be(400); + body.Should().NotContain("PERSISTED_OPERATION_REQUIRED"); + } + + // ----- Batched requests through real HC ----- + + [Test] + public async Task BatchedRequest_AllPersisted_PassesThroughMiddlewareToHC() + { + // HC v15's standard `/graphql` endpoint does not execute JSON-array + // batches; it returns HC0009 "Invalid GraphQL Request". The behavior + // under our control is the persisted-operations middleware: when + // every entry has a persisted id (no inline `query`), the middleware + // must NOT reject and must NOT short-circuit — it must pass the body + // through to HC. We assert that here by checking HC's parser error + // is reached, not the middleware's rejection error. + await Store.UpsertAsync("batch_a_v1", GreetDoc, null, CancellationToken.None); + await Store.UpsertAsync("batch_b_v1", GreetDoc, null, CancellationToken.None); + + var json = + "[{\"id\":\"batch_a_v1\",\"variables\":{\"input\":{\"name\":\"Anna\"}}}," + + "{\"id\":\"batch_b_v1\",\"variables\":{\"input\":{\"name\":\"Bob\"}}}]"; + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + var resp = await Http.PostAsync("/trax/graphql/", content); + var body = await resp.Content.ReadAsStringAsync(); + + body.Should() + .NotContain( + "PERSISTED_OPERATION_REQUIRED", + "the middleware must not reject batches whose entries are all persisted" + ); + // HC's parser error code (HC0009) means the request reached HC's + // parser — i.e. our middleware passed it through. If the request had + // been rejected by the middleware, the body would carry our typed + // PERSISTED_OPERATION_REQUIRED code instead. + body.Should().Contain("HC0009", "request must reach HC, not be short-circuited by us"); + } + + [Test] + public async Task BatchedRequest_OneInlineQuery_RejectsWholeBatch() + { + await Store.UpsertAsync("batch_persisted_v1", GreetDoc, null, CancellationToken.None); + + var json = + "[{\"id\":\"batch_persisted_v1\"}," + + "{\"query\":\"{ discover { greeting { greet(input: { name: \\\"y\\\" }) { greeting } } } }\"}]"; + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + var resp = await Http.PostAsync("/trax/graphql/", content); + ((int)resp.StatusCode).Should().Be(400); + var body = await resp.Content.ReadAsStringAsync(); + body.Should().Contain("PERSISTED_OPERATION_REQUIRED"); + } + + // ----- Multiple inputs proves correct execution ----- + + [Test] + public async Task SamePersistedOperation_DifferentVariables_DispatchesEachToTrain() + { + await Store.UpsertAsync("multivar_v1", GreetDoc, null, CancellationToken.None); + + foreach (var name in new[] { "Anna", "Beth", "Carl", "Diane" }) + { + using var doc = await PostJsonAsync( + new { id = "multivar_v1", variables = new { input = new { name } } } + ); + doc.RootElement.GetProperty("data") + .GetProperty("discover") + .GetProperty("greeting") + .GetProperty("greet") + .GetProperty("greeting") + .GetString() + .Should() + .Be($"Hello, {name}."); + } + } + + // ----- Shape-diff guardrail (E2E sanity) ----- + + [Test] + public async Task ShapeChangingEdit_IsRejectedAtTheStorageLayer() + { + await Store.UpsertAsync("shape_e2e_v1", GreetDoc, null, CancellationToken.None); + + // Add greetedAt → response shape changes. + var changedShape = + "query Greet($input: GreetInput!) { discover { greeting { greet(input: $input) { greeting greetedAt } } } }"; + + Func act = () => + Store.UpsertAsync("shape_e2e_v1", changedShape, null, CancellationToken.None); + + await act.Should().ThrowAsync(); + } +} diff --git a/tests/Trax.Samples.PersistedOperations.E2E/ApiTests/EnforcementTests.cs b/tests/Trax.Samples.PersistedOperations.E2E/ApiTests/EnforcementTests.cs new file mode 100644 index 0000000..f9d1ee5 --- /dev/null +++ b/tests/Trax.Samples.PersistedOperations.E2E/ApiTests/EnforcementTests.cs @@ -0,0 +1,74 @@ +using System.Text.Json; +using FluentAssertions; +using Trax.Samples.PersistedOperations.E2E.Fixtures; + +namespace Trax.Samples.PersistedOperations.E2E.ApiTests; + +[TestFixture] +[Category("E2E")] +public class EnforcementTests : ApiTestBase +{ + private const string GreetDoc = + "query Greet($input: GreetInput!) { discover { greeting { greet(input: $input) { greeting } } } }"; + + [Test] + public async Task InlineQuery_IsRejectedWith400() + { + var resp = await PostAsync( + new { query = GreetDoc, variables = new { input = new { name = "Bob" } } } + ); + + ((int)resp.StatusCode).Should().Be(400); + var body = await resp.Content.ReadAsStringAsync(); + body.Should().Contain("PERSISTED_OPERATION_REQUIRED"); + } + + [Test] + public async Task PersistedId_AfterUpload_DispatchesToTrainAndReturnsResult() + { + await Store.UpsertAsync("greet_v1", GreetDoc, options: null, CancellationToken.None); + + using var doc = await PostJsonAsync( + new { id = "greet_v1", variables = new { input = new { name = "Alice" } } } + ); + + doc.RootElement.TryGetProperty("errors", out _) + .Should() + .BeFalse(doc.RootElement.GetRawText()); + doc.RootElement.GetProperty("data") + .GetProperty("discover") + .GetProperty("greeting") + .GetProperty("greet") + .GetProperty("greeting") + .GetString() + .Should() + .Be("Hello, Alice."); + } + + [Test] + public async Task PersistedId_NotInStore_ReturnsNotFoundError() + { + var resp = await PostAsync( + new { id = "nonexistent_v1", variables = new { input = new { name = "X" } } } + ); + var body = await resp.Content.ReadAsStringAsync(); + body.Should().Contain("errors"); + } + + [Test] + public async Task DevPrefixedOperation_BypassesEnforcement() + { + // The sample configures AllowOperationsMatching(id => id.StartsWith("dev_")). + // An inline query with a dev_-prefixed operation name passes through + // the middleware and HC executes it normally. + var resp = await PostAsync( + new + { + query = "query dev_explore($input: GreetInput!) { discover { greeting { greet(input: $input) { greeting } } } }", + operationName = "dev_explore", + variables = new { input = new { name = "Dev" } }, + } + ); + ((int)resp.StatusCode).Should().Be(200); + } +} diff --git a/tests/Trax.Samples.PersistedOperations.E2E/ApiTests/HotFixFlowTests.cs b/tests/Trax.Samples.PersistedOperations.E2E/ApiTests/HotFixFlowTests.cs new file mode 100644 index 0000000..8f4c595 --- /dev/null +++ b/tests/Trax.Samples.PersistedOperations.E2E/ApiTests/HotFixFlowTests.cs @@ -0,0 +1,166 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Trax.Api.GraphQL.PersistedOperations.Storage; +using Trax.Effect.Data.Services.IDataContextFactory; +using Trax.Samples.PersistedOperations.E2E.Fixtures; + +namespace Trax.Samples.PersistedOperations.E2E.ApiTests; + +[TestFixture] +[Category("E2E")] +public class HotFixFlowTests : ApiTestBase +{ + private const string GreetDoc = + "query Greet($input: GreetInput!) { discover { greeting { greet(input: $input) { greeting } } } }"; + + private const string LookupUserDoc = + "query LookupUser($input: LookupUserInput!) { discover { users { lookupUser(input: $input) { userId displayName email loginCount } } } }"; + + [Test] + public async Task DistinctIds_DispatchToDistinctTrains() + { + await Store.UpsertAsync("greet_distinct_v1", GreetDoc, null, CancellationToken.None); + await Store.UpsertAsync( + "lookupUser_distinct_v1", + LookupUserDoc, + null, + CancellationToken.None + ); + + using var greet = await PostJsonAsync( + new { id = "greet_distinct_v1", variables = new { input = new { name = "Alice" } } } + ); + greet + .RootElement.GetProperty("data") + .GetProperty("discover") + .GetProperty("greeting") + .GetProperty("greet") + .GetProperty("greeting") + .GetString() + .Should() + .Be("Hello, Alice."); + + using var lookup = await PostJsonAsync( + new + { + id = "lookupUser_distinct_v1", + variables = new { input = new { userId = "user-1" } }, + } + ); + lookup + .RootElement.GetProperty("data") + .GetProperty("discover") + .GetProperty("users") + .GetProperty("lookupUser") + .GetProperty("displayName") + .GetString() + .Should() + .Be("User user-1"); + } + + [Test] + public async Task PersistedOperation_WithMultipleVariableInputs_DispatchesEachCorrectly() + { + await Store.UpsertAsync("greet_multi_v1", GreetDoc, null, CancellationToken.None); + + foreach (var name in new[] { "Alice", "Bob", "Charlie", "Dana" }) + { + using var doc = await PostJsonAsync( + new { id = "greet_multi_v1", variables = new { input = new { name } } } + ); + doc.RootElement.GetProperty("data") + .GetProperty("discover") + .GetProperty("greeting") + .GetProperty("greet") + .GetProperty("greeting") + .GetString() + .Should() + .Be($"Hello, {name}."); + } + } + + [Test] + public async Task PersistedOperation_LookupUser_ReturnsTrainResolverOutput() + { + await Store.UpsertAsync("lookup_v1", LookupUserDoc, null, CancellationToken.None); + + using var doc = await PostJsonAsync( + new { id = "lookup_v1", variables = new { input = new { userId = "user-99" } } } + ); + + var user = doc + .RootElement.GetProperty("data") + .GetProperty("discover") + .GetProperty("users") + .GetProperty("lookupUser"); + + user.GetProperty("userId").GetString().Should().Be("user-99"); + user.GetProperty("displayName").GetString().Should().Be("User user-99"); + user.GetProperty("email").GetString().Should().Be("user-99@example.test"); + user.GetProperty("loginCount").GetInt32().Should().BeGreaterThanOrEqualTo(0); + } + + [Test] + public async Task PersistedOperationHistory_AccumulatesEveryMutationInOrder() + { + // Drive the storage through Upsert → Upsert (rewrite) → Deactivate → + // Restore, then assert the history table contains exactly four rows + // in the right ChangeType order. The previous version of this test + // queried ListAsync (live rows) and only confirmed the live row + // existed — it would have passed even if no history rows had been + // written. + const string id = "history_check_v1"; + await Store.UpsertAsync(id, GreetDoc, null, CancellationToken.None); + await Store.UpsertAsync(id, GreetDoc + " # rewrite", null, CancellationToken.None); + await Store.DeactivateAsync(id, null, "test cleanup", CancellationToken.None); + await Store.RestoreAsync(id, null, CancellationToken.None); + + var factory = + SharedApiSetup.Factory!.Services.GetRequiredService(); + var ctx = await factory.CreateDbContextAsync(CancellationToken.None); + + var rows = await ctx + .PersistedOperationHistories.Where(h => h.Id == id) + .OrderBy(h => h.ChangedAt) + .ThenBy(h => h.HistoryId) + .Select(h => h.ChangeType) + .ToListAsync(); + + rows.Should().Equal("Upsert", "Upsert", "Deactivate", "Restore"); + + var deactivateRow = await ctx.PersistedOperationHistories.FirstAsync(h => + h.Id == id && h.ChangeType == "Deactivate" + ); + deactivateRow + .ChangedReason.Should() + .Be("test cleanup", "Deactivate must record the operator-supplied reason"); + } + + [Test] + public async Task DeactivateThenRestore_DispatchResumesAfterRestore() + { + await Store.UpsertAsync("lifecycle_v1", GreetDoc, null, CancellationToken.None); + await Store.DeactivateAsync("lifecycle_v1", null, "test", CancellationToken.None); + + var resp = await PostAsync( + new { id = "lifecycle_v1", variables = new { input = new { name = "X" } } } + ); + var body = await resp.Content.ReadAsStringAsync(); + body.Should().Contain("errors"); + + await Store.RestoreAsync("lifecycle_v1", null, CancellationToken.None); + + using var doc = await PostJsonAsync( + new { id = "lifecycle_v1", variables = new { input = new { name = "Y" } } } + ); + doc.RootElement.GetProperty("data") + .GetProperty("discover") + .GetProperty("greeting") + .GetProperty("greet") + .GetProperty("greeting") + .GetString() + .Should() + .Be("Hello, Y."); + } +} diff --git a/tests/Trax.Samples.PersistedOperations.E2E/Fixtures/ApiTestBase.cs b/tests/Trax.Samples.PersistedOperations.E2E/Fixtures/ApiTestBase.cs new file mode 100644 index 0000000..6a1e43d --- /dev/null +++ b/tests/Trax.Samples.PersistedOperations.E2E/Fixtures/ApiTestBase.cs @@ -0,0 +1,50 @@ +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Trax.Api.GraphQL.PersistedOperations.Storage; + +namespace Trax.Samples.PersistedOperations.E2E.Fixtures; + +/// +/// Shared base for E2E tests. Provides an HTTP client wired to the in-process +/// API and a fresh reference per test. +/// +public abstract class ApiTestBase +{ + protected HttpClient Http { get; private set; } = null!; + protected IPersistedOperationStore Store { get; private set; } = null!; + + [SetUp] + public async Task BaseSetUpAsync() + { + if (SharedApiSetup.Skipped || SharedApiSetup.Factory is null) + Assert.Ignore("Postgres / API factory not reachable. Run docker compose up -d."); + + // Each test starts with empty persisted-operation tables so id reuse + // across cases does not trigger the shape-diff guardrail against + // stale state. + await SharedApiSetup.ClearAsync(SharedApiSetup.Factory.Services); + + Http = SharedApiSetup.Factory.CreateClient(); + Store = SharedApiSetup.Factory.Services.GetRequiredService(); + } + + [TearDown] + public void BaseTearDown() => Http?.Dispose(); + + /// + /// POST a GraphQL request body, return the raw response. + /// + protected async Task PostAsync(object body) => + await Http.PostAsJsonAsync("/trax/graphql/", body); + + /// + /// POST and parse the response body as JSON. + /// + protected async Task PostJsonAsync(object body) + { + var resp = await PostAsync(body); + var text = await resp.Content.ReadAsStringAsync(); + return JsonDocument.Parse(text); + } +} diff --git a/tests/Trax.Samples.PersistedOperations.E2E/Fixtures/PersistedOperationsApiFactory.cs b/tests/Trax.Samples.PersistedOperations.E2E/Fixtures/PersistedOperationsApiFactory.cs new file mode 100644 index 0000000..bc3c0c5 --- /dev/null +++ b/tests/Trax.Samples.PersistedOperations.E2E/Fixtures/PersistedOperationsApiFactory.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace Trax.Samples.PersistedOperations.E2E.Fixtures; + +/// +/// WebApplicationFactory hosting Trax.Samples.PersistedOperations.Api +/// in-process. Tests POST against the GraphQL endpoint via the factory's +/// HttpClient (no Kestrel binding, no port collisions). +/// +public sealed class PersistedOperationsApiFactory : WebApplicationFactory +{ + /// + /// Override knobs for individual test classes (e.g., flip + /// RequirePersisted off for shadow-mode tests). Default is null + /// (use whatever the sample's Program.cs configures). + /// + public Action? Configure { get; set; } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + // The sample reads connection strings from configuration; in-process + // tests assume the same docker-compose Postgres the sample uses. + Configure?.Invoke(builder); + } +} diff --git a/tests/Trax.Samples.PersistedOperations.E2E/Fixtures/SharedApiSetup.cs b/tests/Trax.Samples.PersistedOperations.E2E/Fixtures/SharedApiSetup.cs new file mode 100644 index 0000000..a44d8be --- /dev/null +++ b/tests/Trax.Samples.PersistedOperations.E2E/Fixtures/SharedApiSetup.cs @@ -0,0 +1,75 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Trax.Api.GraphQL.PersistedOperations.Storage; +using Trax.Effect.Data.Services.IDataContextFactory; +using Trax.Samples.PersistedOperations.E2E.Fixtures; + +namespace Trax.Samples.PersistedOperations.E2E; + +/// +/// One-time setup: builds the API factory, ensures the persisted-operation +/// store is reachable, and seeds a clean state. +/// +[SetUpFixture] +public class SharedApiSetup +{ + public static PersistedOperationsApiFactory? Factory { get; private set; } + + public static bool Skipped { get; private set; } + + [OneTimeSetUp] + public async Task OneTimeSetUp() + { + try + { + Factory = new PersistedOperationsApiFactory(); + _ = Factory.Services; + await ClearAsync(Factory.Services); + } + catch (Exception ex) + { + Skipped = true; + await (Factory?.DisposeAsync().AsTask() ?? Task.CompletedTask); + Factory = null; + if ( + ex is not Npgsql.NpgsqlException + && ex.InnerException is not Npgsql.NpgsqlException + && ex is not System.Net.Sockets.SocketException + ) + throw; + } + } + + [OneTimeTearDown] + public async Task OneTimeTearDown() + { + if (Factory is null) + return; + try + { + await ClearAsync(Factory.Services); + } + catch + { + // Best-effort. + } + await Factory.DisposeAsync(); + } + + /// + /// Hard-deletes every row in trax.persisted_operation and + /// trax.persisted_operation_history so each test starts clean. + /// Tests in this suite reuse a small set of ids (e.g. greet_v1) + /// across cases and rely on the shape-diff guardrail being evaluated + /// against a fresh insert, not a stale row from a previous test. + /// + public static async Task ClearAsync(IServiceProvider services) + { + var factory = services.GetRequiredService(); + var ctx = await factory.CreateDbContextAsync(CancellationToken.None); + await ctx.PersistedOperationHistories.ExecuteDeleteAsync(); + await ctx.PersistedOperations.ExecuteDeleteAsync(); + if (ctx is IAsyncDisposable async) + await async.DisposeAsync(); + } +} diff --git a/tests/Trax.Samples.PersistedOperations.E2E/Trax.Samples.PersistedOperations.E2E.csproj b/tests/Trax.Samples.PersistedOperations.E2E/Trax.Samples.PersistedOperations.E2E.csproj new file mode 100644 index 0000000..fa4bb4b --- /dev/null +++ b/tests/Trax.Samples.PersistedOperations.E2E/Trax.Samples.PersistedOperations.E2E.csproj @@ -0,0 +1,29 @@ + + + net10.0 + enable + enable + false + true + Trax.Samples.PersistedOperations.E2E + + + + + + + + + + + + + + + + + + + + +