|
| 1 | +using System.Text; |
| 2 | +using DotNet.Testcontainers.Builders; |
| 3 | +using DotNet.Testcontainers.Containers; |
| 4 | +using HotChocolate.Execution; |
| 5 | +using HotChocolate.Fusion.Subscriptions.NATS; |
| 6 | +using HotChocolate.Transport.Http; |
| 7 | +using HotChocolate.Types.Composite; |
| 8 | +using HotChocolate.Types.Relay; |
| 9 | +using Microsoft.Extensions.DependencyInjection; |
| 10 | +using NATS.Client.Core; |
| 11 | +using NATS.Client.JetStream; |
| 12 | +using NATS.Client.JetStream.Models; |
| 13 | +using OperationRequest = HotChocolate.Transport.OperationRequest; |
| 14 | +using OperationResult = HotChocolate.Transport.OperationResult; |
| 15 | + |
| 16 | +namespace HotChocolate.Fusion; |
| 17 | + |
| 18 | +/// <summary> |
| 19 | +/// End-to-end coverage for a Federated Event Stream whose selected fields require an enrichment |
| 20 | +/// lookup that the source schema rejects. Models the fusion-demo-sfo Reviews subgraph: a relay |
| 21 | +/// Node <c>Review</c> (so <c>Review.id</c> is a global id and the enrichment runs through |
| 22 | +/// <c>node(id:)</c>), an <c>@eventStream</c> message that only carries the id, and a client that |
| 23 | +/// also selects <c>body</c>. When an event carries an id that is not a valid global id the lookup |
| 24 | +/// errors; the subscription must surface that as a per-event error result (subgraph error plus |
| 25 | +/// standard non-null propagation) and stay alive, not silently close. |
| 26 | +/// </summary> |
| 27 | +public class EventStreamEnrichmentErrorOverHttpTests : FusionTestBase |
| 28 | +{ |
| 29 | + private const string BrokerName = "nats"; |
| 30 | + private const string Topic = "onCreateReview"; |
| 31 | + private const string GatewayUrl = "http://localhost:5000/graphql"; |
| 32 | + |
| 33 | + [Fact] |
| 34 | + public async Task Subscribe_Should_ReturnErrorAndStayAlive_When_EnrichmentLookupRejectsEventId() |
| 35 | + { |
| 36 | + // arrange |
| 37 | + await using var nats = await JetStreamNatsFixture.StartAsync(); |
| 38 | + var stream = "S" + Guid.NewGuid().ToString("N"); |
| 39 | + var durable = "D" + Guid.NewGuid().ToString("N"); |
| 40 | + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); |
| 41 | + |
| 42 | + await CreateStreamAsync(nats.Url, stream, Topic, cts.Token); |
| 43 | + |
| 44 | + using var events = CreateSourceSchema( |
| 45 | + "EVENTS", |
| 46 | + b => b |
| 47 | + .AddQueryType<ReviewsApi.Query>() |
| 48 | + .AddSubscriptionType<ReviewsApi.Subscription>() |
| 49 | + .AddGlobalObjectIdentification(o => o.MarkNodeFieldAsLookup = true) |
| 50 | + .ModifyOptions(o => o.StrictValidation = false)); |
| 51 | + |
| 52 | + using var gateway = await CreateCompositeSchemaAsync( |
| 53 | + [("EVENTS", events)], |
| 54 | + configureServices: services => services.AddNatsEventStreamBroker( |
| 55 | + BrokerName, |
| 56 | + o => |
| 57 | + { |
| 58 | + o.Url = nats.Url; |
| 59 | + o.JetStream = new NatsJetStreamOptions { Stream = stream, DurableConsumer = durable }; |
| 60 | + }), |
| 61 | + configureGatewayBuilder: b => b.ModifyRequestOptions(o => o.AllowOperationPlanRequests = false)); |
| 62 | + |
| 63 | + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); |
| 64 | + |
| 65 | + var request = new OperationRequest( |
| 66 | + """ |
| 67 | + subscription { |
| 68 | + onCreateReview { |
| 69 | + review { |
| 70 | + id |
| 71 | + body |
| 72 | + } |
| 73 | + cursor |
| 74 | + } |
| 75 | + } |
| 76 | + """); |
| 77 | + |
| 78 | + // The encoded global id the second (valid) event publishes, matching what Review.id is. |
| 79 | + var schema = await events.Services.GetSchemaAsync("EVENTS", cts.Token); |
| 80 | + var validId = schema.Services |
| 81 | + .GetRequiredService<INodeIdSerializerAccessor>() |
| 82 | + .Serializer |
| 83 | + .Format("Review", 1); |
| 84 | + |
| 85 | + // act |
| 86 | + // The first event carries the raw database key (not a valid global id), so the node lookup |
| 87 | + // that fetches `body` fails. The second event carries the encoded global id and resolves. |
| 88 | + using var response = await client.PostAsync(request, new Uri(GatewayUrl), cts.Token); |
| 89 | + await PublishAsync(nats.Url, Topic, """{"review":{"id":1}}""", cts.Token); |
| 90 | + await PublishAsync(nats.Url, Topic, "{\"review\":{\"id\":\"" + validId + "\"}}", cts.Token); |
| 91 | + |
| 92 | + var results = new List<OperationResult>(); |
| 93 | + await foreach (var result in response.ReadAsResultStreamAsync().WithCancellation(cts.Token)) |
| 94 | + { |
| 95 | + results.Add(result); |
| 96 | + |
| 97 | + if (results.Count == 2) |
| 98 | + { |
| 99 | + break; |
| 100 | + } |
| 101 | + } |
| 102 | + |
| 103 | + // assert |
| 104 | + // First event: the message carried a raw int id, so the relay `node(id: ID!)` enrichment |
| 105 | + // lookup is fed an int where a string global id is required. That error is integrated and |
| 106 | + // `body` (non-null) null-propagates to the non-null root; the stream is NOT torn down. |
| 107 | + // Second event: the stream recovered and delivered the enriched review. |
| 108 | + Assert.Equal(2, results.Count); |
| 109 | + results.MatchInlineSnapshots( |
| 110 | + [ |
| 111 | + """ |
| 112 | + { |
| 113 | + "data": null, |
| 114 | + "errors": [ |
| 115 | + { |
| 116 | + "message": "The argument literal representation is `HotChocolate.Language.IntValueNode` which is not compatible with the request literal type `HotChocolate.Language.StringValueNode`.", |
| 117 | + "path": [ |
| 118 | + "onCreateReview", |
| 119 | + "review", |
| 120 | + "body" |
| 121 | + ], |
| 122 | + "extensions": { |
| 123 | + "fieldName": "node", |
| 124 | + "argumentName": "id", |
| 125 | + "requestedType": "HotChocolate.Language.StringValueNode", |
| 126 | + "actualType": "HotChocolate.Language.IntValueNode" |
| 127 | + } |
| 128 | + } |
| 129 | + ] |
| 130 | + } |
| 131 | + """, |
| 132 | + """ |
| 133 | + { |
| 134 | + "data": { |
| 135 | + "onCreateReview": { |
| 136 | + "review": { |
| 137 | + "id": "UmV2aWV3OjE=", |
| 138 | + "body": "A great read" |
| 139 | + }, |
| 140 | + "cursor": "Mg==" |
| 141 | + } |
| 142 | + } |
| 143 | + } |
| 144 | + """ |
| 145 | + ]); |
| 146 | + } |
| 147 | + |
| 148 | + private static async Task CreateStreamAsync(string url, string stream, string subject, CancellationToken ct) |
| 149 | + { |
| 150 | + await using var connection = new NatsConnection(new NatsOpts { Url = url }); |
| 151 | + var js = new NatsJSContext(connection); |
| 152 | + await js.CreateStreamAsync(new StreamConfig { Name = stream, Subjects = [subject] }, ct); |
| 153 | + } |
| 154 | + |
| 155 | + private static async Task PublishAsync(string url, string subject, string body, CancellationToken ct) |
| 156 | + { |
| 157 | + await using var connection = new NatsConnection(new NatsOpts { Url = url }); |
| 158 | + var js = new NatsJSContext(connection); |
| 159 | + await js.PublishAsync(subject, Encoding.UTF8.GetBytes(body), cancellationToken: ct); |
| 160 | + } |
| 161 | + |
| 162 | + public static class ReviewsApi |
| 163 | + { |
| 164 | + public class Query |
| 165 | + { |
| 166 | + public string Version => "1.0.0"; |
| 167 | + |
| 168 | + // Mirrors the demo's `[Lookup, NodeResolver] GetReviewByIdAsync(int id)`. |
| 169 | + [Lookup, NodeResolver] |
| 170 | + public Review? GetReviewById(int id) |
| 171 | + => id == 1 ? new Review(1, "A great read") : null; |
| 172 | + } |
| 173 | + |
| 174 | + public class Subscription |
| 175 | + { |
| 176 | + [EventStream("review { id }", Topic = Topic, Broker = BrokerName)] |
| 177 | + public ReviewCreated OnCreateReview([EventCursor] string? after) |
| 178 | + => EventStream.Create<ReviewCreated>(after); |
| 179 | + } |
| 180 | + |
| 181 | + public record ReviewCreated(Review Review, [property: EventCursor] string Cursor); |
| 182 | + |
| 183 | + public record Review(int Id, string Body); |
| 184 | + } |
| 185 | + |
| 186 | + private sealed class JetStreamNatsFixture : IAsyncDisposable |
| 187 | + { |
| 188 | + private readonly IContainer _container; |
| 189 | + |
| 190 | + private JetStreamNatsFixture(IContainer container) => _container = container; |
| 191 | + |
| 192 | + public string Url => $"nats://localhost:{_container.GetMappedPublicPort(4222)}"; |
| 193 | + |
| 194 | + public static async Task<JetStreamNatsFixture> StartAsync() |
| 195 | + { |
| 196 | + var fixture = new JetStreamNatsFixture( |
| 197 | + new ContainerBuilder("nats:2.10-alpine") |
| 198 | + .WithPortBinding(4222, assignRandomHostPort: true) |
| 199 | + .WithCommand("-js") |
| 200 | + .WithWaitStrategy(Wait.ForUnixContainer().UntilInternalTcpPortIsAvailable(4222)) |
| 201 | + .Build()); |
| 202 | + |
| 203 | + await fixture._container.StartAsync(); |
| 204 | + await fixture.WaitForConnectionAsync(); |
| 205 | + return fixture; |
| 206 | + } |
| 207 | + |
| 208 | + private async Task WaitForConnectionAsync() |
| 209 | + { |
| 210 | + var attempt = 0; |
| 211 | + while (true) |
| 212 | + { |
| 213 | + try |
| 214 | + { |
| 215 | + await using var connection = new NatsConnection(new NatsOpts { Url = Url }); |
| 216 | + await connection.ConnectAsync(); |
| 217 | + return; |
| 218 | + } |
| 219 | + catch (NatsException) when (++attempt < 20) |
| 220 | + { |
| 221 | + await Task.Delay(TimeSpan.FromMilliseconds(250)); |
| 222 | + } |
| 223 | + } |
| 224 | + } |
| 225 | + |
| 226 | + public async ValueTask DisposeAsync() => await _container.DisposeAsync(); |
| 227 | + } |
| 228 | +} |
0 commit comments