Fix E2E test gaps and add comprehensive E2E + parity test suites

- Fix pull consumer fetch: send original stream subject in HMSG (not inbox)
  so NATS client distinguishes data messages from control messages
- Fix MaxAge expiry: add background timer in StreamManager for periodic pruning
- Fix JetStream wire format: Go-compatible anonymous objects with string enums,
  proper offset-based pagination for stream/consumer list APIs
- Add 42 E2E black-box tests (core messaging, auth, TLS, accounts, JetStream)
- Add ~1000 parity tests across all subsystems (gaps closure)
- Update gap inventory docs to reflect implementation status
This commit is contained in:
Joseph Doherty
2026-03-12 14:09:23 -04:00
parent 79c1ee8776
commit c30e67a69d
226 changed files with 17801 additions and 709 deletions

View File

@@ -9,7 +9,9 @@ public sealed class JetStreamApiResponse
public JetStreamConsumerInfo? ConsumerInfo { get; init; }
public JetStreamAccountInfo? AccountInfo { get; init; }
public IReadOnlyList<string>? StreamNames { get; init; }
public IReadOnlyList<JetStreamStreamInfo>? StreamInfoList { get; init; }
public IReadOnlyList<string>? ConsumerNames { get; init; }
public IReadOnlyList<JetStreamConsumerInfo>? ConsumerInfoList { get; init; }
public JetStreamStreamMessage? StreamMessage { get; init; }
public JetStreamDirectMessage? DirectMessage { get; init; }
public JetStreamSnapshot? Snapshot { get; init; }
@@ -17,6 +19,17 @@ public sealed class JetStreamApiResponse
public bool Success { get; init; }
public ulong Purged { get; init; }
/// <summary>
/// Total count of all items (before pagination). Used by list responses for offset-based pagination.
/// Go reference: jetstream_api.go — ApiPaged struct includes Total, Offset, Limit fields.
/// </summary>
public int PaginationTotal { get; init; }
/// <summary>
/// Requested offset for pagination. Echoed back to client so it can calculate the next page.
/// </summary>
public int PaginationOffset { get; init; }
/// <summary>
/// Whether the consumer is currently paused. Populated by pause/resume API responses.
/// Go reference: server/consumer.go jsConsumerPauseResponse.paused field.
@@ -29,6 +42,123 @@ public sealed class JetStreamApiResponse
/// </summary>
public DateTime? PauseUntil { get; init; }
/// <summary>
/// Returns a wire-format object for JSON serialization matching the Go server's
/// flat response structure (e.g., config/state at root level for stream responses,
/// not nested under a wrapper property).
/// </summary>
public object ToWireFormat()
{
if (StreamInfo != null)
{
if (Error != null)
return new { type = "io.nats.jetstream.api.v1.stream_create_response", error = Error };
return new
{
type = "io.nats.jetstream.api.v1.stream_create_response",
config = ToWireConfig(StreamInfo.Config),
state = ToWireState(StreamInfo.State),
};
}
if (ConsumerInfo != null)
{
if (Error != null)
return new { type = "io.nats.jetstream.api.v1.consumer_create_response", error = Error };
return new
{
type = "io.nats.jetstream.api.v1.consumer_create_response",
stream_name = ConsumerInfo.StreamName,
name = ConsumerInfo.Name,
config = ToWireConsumerConfig(ConsumerInfo.Config),
};
}
if (Error != null)
return new { error = Error };
if (StreamInfoList != null)
{
var wireStreams = StreamInfoList.Select(s => new
{
config = ToWireConfig(s.Config),
state = ToWireState(s.State),
}).ToList();
return new { total = PaginationTotal, offset = PaginationOffset, limit = wireStreams.Count, streams = wireStreams };
}
if (StreamNames != null)
return new { total = PaginationTotal, offset = PaginationOffset, limit = StreamNames.Count, streams = StreamNames };
if (ConsumerInfoList != null)
{
var wireConsumers = ConsumerInfoList.Select(c => new
{
stream_name = c.StreamName,
name = c.Name,
config = ToWireConsumerConfig(c.Config),
}).ToList();
return new { total = PaginationTotal, offset = PaginationOffset, limit = wireConsumers.Count, consumers = wireConsumers };
}
if (ConsumerNames != null)
return new { total = PaginationTotal, offset = PaginationOffset, limit = ConsumerNames.Count, consumers = ConsumerNames };
if (Purged > 0 || Success)
return new { success = Success, purged = Purged };
return new { success = Success };
}
/// <summary>
/// Creates a Go-compatible wire format for StreamConfig.
/// Only includes fields the Go server sends, with enums as lowercase strings.
/// Go reference: server/stream.go StreamConfig JSON marshaling.
/// </summary>
private static object ToWireConfig(StreamConfig c) => new
{
name = c.Name,
subjects = c.Subjects,
retention = c.Retention.ToString().ToLowerInvariant(),
max_consumers = c.MaxConsumers,
max_msgs = c.MaxMsgs,
max_bytes = c.MaxBytes,
max_age = c.MaxAge,
max_msgs_per_subject = c.MaxMsgsPer,
max_msg_size = c.MaxMsgSize,
storage = c.Storage.ToString().ToLowerInvariant(),
discard = c.Discard.ToString().ToLowerInvariant(),
num_replicas = c.Replicas,
duplicate_window = (long)c.DuplicateWindowMs * 1_000_000L,
sealed_field = c.Sealed,
deny_delete = c.DenyDelete,
deny_purge = c.DenyPurge,
allow_direct = c.AllowDirect,
first_seq = c.FirstSeq,
};
private static object ToWireState(ApiStreamState s) => new
{
messages = s.Messages,
bytes = s.Bytes,
first_seq = s.FirstSeq,
last_seq = s.LastSeq,
consumer_count = 0,
};
private static object ToWireConsumerConfig(ConsumerConfig c) => new
{
durable_name = string.IsNullOrEmpty(c.DurableName) ? null : c.DurableName,
name = string.IsNullOrEmpty(c.DurableName) ? null : c.DurableName,
deliver_policy = c.DeliverPolicy.ToString().ToLowerInvariant(),
ack_policy = c.AckPolicy.ToString().ToLowerInvariant(),
replay_policy = c.ReplayPolicy.ToString().ToLowerInvariant(),
ack_wait = (long)c.AckWaitMs * 1_000_000L,
max_deliver = c.MaxDeliver,
max_ack_pending = c.MaxAckPending,
filter_subject = c.FilterSubject,
};
public static JetStreamApiResponse NotFound(string subject) => new()
{
Error = new JetStreamApiError
@@ -99,6 +229,8 @@ public sealed class JetStreamStreamInfo
public sealed class JetStreamConsumerInfo
{
public string? Name { get; init; }
public string? StreamName { get; init; }
public required ConsumerConfig Config { get; init; }
}