refactor: extract NATS.Server.JetStream.Tests project
Move 225 JetStream-related test files from NATS.Server.Tests into a dedicated NATS.Server.JetStream.Tests project. This includes root-level JetStream*.cs files, storage test files (FileStore, MemStore, StreamStoreContract), and the full JetStream/ subfolder tree (Api, Cluster, Consumers, MirrorSource, Snapshots, Storage, Streams). Updated all namespaces, added InternalsVisibleTo, registered in the solution file, and added the JETSTREAM_INTEGRATION_MATRIX define.
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
// Ported from golang/nats-server/server/jetstream_test.go
|
||||
// Go reference: server/stream.go:1500-1600 (stream.update immutable field validation)
|
||||
// Covers: TestJetStreamStreamUpdate, TestJetStreamStreamUpdateMaxConsumers
|
||||
|
||||
using NATS.Server.JetStream;
|
||||
using NATS.Server.JetStream.Api;
|
||||
using NATS.Server.JetStream.Models;
|
||||
using Shouldly;
|
||||
|
||||
namespace NATS.Server.JetStream.Tests.JetStream.Streams;
|
||||
|
||||
public class ConfigUpdateValidationTests
|
||||
{
|
||||
// Go ref: server/stream.go:1500-1600 (stream.update)
|
||||
// A valid update that only changes mutable fields (MaxMsgs) should produce no errors.
|
||||
[Fact]
|
||||
public void ValidateConfigUpdate_allows_valid_changes()
|
||||
{
|
||||
var existing = new StreamConfig
|
||||
{
|
||||
Name = "ORDERS",
|
||||
Storage = StorageType.Memory,
|
||||
Retention = RetentionPolicy.Limits,
|
||||
Subjects = ["orders.*"],
|
||||
MaxMsgs = 100,
|
||||
};
|
||||
var proposed = new StreamConfig
|
||||
{
|
||||
Name = "ORDERS",
|
||||
Storage = StorageType.Memory,
|
||||
Retention = RetentionPolicy.Limits,
|
||||
Subjects = ["orders.*"],
|
||||
MaxMsgs = 500,
|
||||
};
|
||||
|
||||
var errors = StreamManager.ValidateConfigUpdate(existing, proposed);
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// Go ref: server/stream.go:1511-1513 (storage type immutability check)
|
||||
// Changing storage type from Memory to File must be rejected.
|
||||
[Fact]
|
||||
public void ValidateConfigUpdate_rejects_storage_type_change()
|
||||
{
|
||||
var existing = new StreamConfig
|
||||
{
|
||||
Name = "ORDERS",
|
||||
Storage = StorageType.Memory,
|
||||
Subjects = ["orders.*"],
|
||||
};
|
||||
var proposed = new StreamConfig
|
||||
{
|
||||
Name = "ORDERS",
|
||||
Storage = StorageType.File,
|
||||
Subjects = ["orders.*"],
|
||||
};
|
||||
|
||||
var errors = StreamManager.ValidateConfigUpdate(existing, proposed);
|
||||
|
||||
errors.ShouldContain(e => e.Contains("storage type"));
|
||||
}
|
||||
|
||||
// Go ref: server/stream.go:1530-1535 (mirror immutability)
|
||||
// Changing the mirror origin must be rejected.
|
||||
[Fact]
|
||||
public void ValidateConfigUpdate_rejects_mirror_change()
|
||||
{
|
||||
var existing = new StreamConfig
|
||||
{
|
||||
Name = "MIRROR_STREAM",
|
||||
Storage = StorageType.Memory,
|
||||
Mirror = "ORIGIN_A",
|
||||
};
|
||||
var proposed = new StreamConfig
|
||||
{
|
||||
Name = "MIRROR_STREAM",
|
||||
Storage = StorageType.Memory,
|
||||
Mirror = "ORIGIN_B",
|
||||
};
|
||||
|
||||
var errors = StreamManager.ValidateConfigUpdate(existing, proposed);
|
||||
|
||||
errors.ShouldContain(e => e.Contains("mirror configuration"));
|
||||
}
|
||||
|
||||
// Go ref: server/stream.go:1520-1525 (retention policy immutability)
|
||||
// Changing the retention policy must be rejected.
|
||||
[Fact]
|
||||
public void ValidateConfigUpdate_rejects_retention_change()
|
||||
{
|
||||
var existing = new StreamConfig
|
||||
{
|
||||
Name = "ORDERS",
|
||||
Storage = StorageType.Memory,
|
||||
Retention = RetentionPolicy.Limits,
|
||||
Subjects = ["orders.*"],
|
||||
};
|
||||
var proposed = new StreamConfig
|
||||
{
|
||||
Name = "ORDERS",
|
||||
Storage = StorageType.Memory,
|
||||
Retention = RetentionPolicy.WorkQueue,
|
||||
Subjects = ["orders.*"],
|
||||
};
|
||||
|
||||
var errors = StreamManager.ValidateConfigUpdate(existing, proposed);
|
||||
|
||||
errors.ShouldContain(e => e.Contains("retention policy"));
|
||||
}
|
||||
|
||||
// Go ref: server/stream.go:1500-1502 (sealed stream guard)
|
||||
// Any modification attempt on a sealed stream must be rejected.
|
||||
[Fact]
|
||||
public void ValidateConfigUpdate_rejects_sealed_stream_changes()
|
||||
{
|
||||
var existing = new StreamConfig
|
||||
{
|
||||
Name = "SEALED",
|
||||
Storage = StorageType.Memory,
|
||||
Sealed = true,
|
||||
Subjects = ["sealed.*"],
|
||||
};
|
||||
var proposed = new StreamConfig
|
||||
{
|
||||
Name = "SEALED",
|
||||
Storage = StorageType.Memory,
|
||||
Sealed = true,
|
||||
Subjects = ["sealed.new.*"],
|
||||
};
|
||||
|
||||
var errors = StreamManager.ValidateConfigUpdate(existing, proposed);
|
||||
|
||||
errors.ShouldContain(e => e.Contains("sealed stream"));
|
||||
}
|
||||
|
||||
// Go ref: server/stream.go:1537-1542 (sources immutability)
|
||||
// Changing the sources list after creation must be rejected.
|
||||
[Fact]
|
||||
public void ValidateConfigUpdate_rejects_source_change()
|
||||
{
|
||||
var existing = new StreamConfig
|
||||
{
|
||||
Name = "AGG",
|
||||
Storage = StorageType.Memory,
|
||||
Sources =
|
||||
[
|
||||
new StreamSourceConfig { Name = "SRC_A" },
|
||||
new StreamSourceConfig { Name = "SRC_B" },
|
||||
],
|
||||
};
|
||||
var proposed = new StreamConfig
|
||||
{
|
||||
Name = "AGG",
|
||||
Storage = StorageType.Memory,
|
||||
Sources =
|
||||
[
|
||||
new StreamSourceConfig { Name = "SRC_A" },
|
||||
new StreamSourceConfig { Name = "SRC_C" },
|
||||
],
|
||||
};
|
||||
|
||||
var errors = StreamManager.ValidateConfigUpdate(existing, proposed);
|
||||
|
||||
errors.ShouldContain(e => e.Contains("sources cannot be changed"));
|
||||
}
|
||||
|
||||
// Go ref: server/jetstream.go — subject overlap detection between streams.
|
||||
// Proposing subjects that collide with another stream's subjects must be rejected.
|
||||
[Fact]
|
||||
public void ValidateConfigUpdate_detects_subject_overlap()
|
||||
{
|
||||
var existing = new StreamConfig
|
||||
{
|
||||
Name = "ORDERS",
|
||||
Storage = StorageType.Memory,
|
||||
Subjects = ["orders.*"],
|
||||
};
|
||||
var proposed = new StreamConfig
|
||||
{
|
||||
Name = "ORDERS",
|
||||
Storage = StorageType.Memory,
|
||||
Subjects = ["orders.>"],
|
||||
};
|
||||
var otherStreams = new[]
|
||||
{
|
||||
new StreamConfig
|
||||
{
|
||||
Name = "ARCHIVE",
|
||||
Storage = StorageType.Memory,
|
||||
Subjects = ["orders.archived"],
|
||||
},
|
||||
};
|
||||
|
||||
var errors = StreamManager.ValidateConfigUpdate(existing, proposed, otherStreams);
|
||||
|
||||
errors.ShouldContain(e => e.Contains("ARCHIVE"));
|
||||
}
|
||||
|
||||
// Go ref: server/jetstream.go — no error for non-overlapping subject sets.
|
||||
// Proposing subjects that do not overlap with other streams must succeed.
|
||||
[Fact]
|
||||
public void ValidateConfigUpdate_allows_non_overlapping_subjects()
|
||||
{
|
||||
var existing = new StreamConfig
|
||||
{
|
||||
Name = "ORDERS",
|
||||
Storage = StorageType.Memory,
|
||||
Subjects = ["orders.*"],
|
||||
};
|
||||
var proposed = new StreamConfig
|
||||
{
|
||||
Name = "ORDERS",
|
||||
Storage = StorageType.Memory,
|
||||
Subjects = ["orders.>"],
|
||||
};
|
||||
var otherStreams = new[]
|
||||
{
|
||||
new StreamConfig
|
||||
{
|
||||
Name = "EVENTS",
|
||||
Storage = StorageType.Memory,
|
||||
Subjects = ["events.*"],
|
||||
},
|
||||
};
|
||||
|
||||
var errors = StreamManager.ValidateConfigUpdate(existing, proposed, otherStreams);
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// Go ref: server/stream.go — MaxConsumers may not be decreased.
|
||||
// Decreasing MaxConsumers from a positive value must be rejected.
|
||||
[Fact]
|
||||
public void ValidateConfigUpdate_rejects_max_consumers_decrease()
|
||||
{
|
||||
var existing = new StreamConfig
|
||||
{
|
||||
Name = "ORDERS",
|
||||
Storage = StorageType.Memory,
|
||||
Subjects = ["orders.*"],
|
||||
MaxConsumers = 10,
|
||||
};
|
||||
var proposed = new StreamConfig
|
||||
{
|
||||
Name = "ORDERS",
|
||||
Storage = StorageType.Memory,
|
||||
Subjects = ["orders.*"],
|
||||
MaxConsumers = 5,
|
||||
};
|
||||
|
||||
var errors = StreamManager.ValidateConfigUpdate(existing, proposed);
|
||||
|
||||
errors.ShouldContain(e => e.Contains("max consumers can only be increased"));
|
||||
}
|
||||
|
||||
// Go ref: server/stream.go — MaxConsumers may be raised without restriction.
|
||||
[Fact]
|
||||
public void ValidateConfigUpdate_allows_max_consumers_increase()
|
||||
{
|
||||
var existing = new StreamConfig
|
||||
{
|
||||
Name = "ORDERS",
|
||||
Storage = StorageType.Memory,
|
||||
Subjects = ["orders.*"],
|
||||
MaxConsumers = 5,
|
||||
};
|
||||
var proposed = new StreamConfig
|
||||
{
|
||||
Name = "ORDERS",
|
||||
Storage = StorageType.Memory,
|
||||
Subjects = ["orders.*"],
|
||||
MaxConsumers = 20,
|
||||
};
|
||||
|
||||
var errors = StreamManager.ValidateConfigUpdate(existing, proposed);
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// Go ref: server/stream.go — RAFT consensus requires an odd number of replicas.
|
||||
// Setting replicas to an even number must be rejected.
|
||||
[Fact]
|
||||
public void ValidateConfigUpdate_rejects_even_replicas()
|
||||
{
|
||||
var existing = new StreamConfig
|
||||
{
|
||||
Name = "ORDERS",
|
||||
Storage = StorageType.Memory,
|
||||
Subjects = ["orders.*"],
|
||||
Replicas = 1,
|
||||
};
|
||||
var proposed = new StreamConfig
|
||||
{
|
||||
Name = "ORDERS",
|
||||
Storage = StorageType.Memory,
|
||||
Subjects = ["orders.*"],
|
||||
Replicas = 2,
|
||||
};
|
||||
|
||||
var errors = StreamManager.ValidateConfigUpdate(existing, proposed);
|
||||
|
||||
errors.ShouldContain(e => e.Contains("replicas must be odd"));
|
||||
}
|
||||
|
||||
// Go ref: server/stream.go:1500-1600 (stream.update) — integration via StreamManager.
|
||||
// CreateOrUpdate must reject an update that changes storage type.
|
||||
[Fact]
|
||||
public void CreateOrUpdate_rejects_invalid_config_update()
|
||||
{
|
||||
var manager = new StreamManager();
|
||||
|
||||
var createResult = manager.CreateOrUpdate(new StreamConfig
|
||||
{
|
||||
Name = "EVENTS",
|
||||
Storage = StorageType.Memory,
|
||||
Subjects = ["events.*"],
|
||||
});
|
||||
createResult.Error.ShouldBeNull();
|
||||
|
||||
var updateResult = manager.CreateOrUpdate(new StreamConfig
|
||||
{
|
||||
Name = "EVENTS",
|
||||
Storage = StorageType.File,
|
||||
Subjects = ["events.*"],
|
||||
});
|
||||
|
||||
updateResult.Error.ShouldNotBeNull();
|
||||
updateResult.Error!.Description.ShouldContain("storage type");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
using NATS.Server.JetStream.MirrorSource;
|
||||
using NATS.Server.JetStream.Models;
|
||||
using NATS.Server.JetStream.Storage;
|
||||
using Shouldly;
|
||||
|
||||
namespace NATS.Server.JetStream.Tests.JetStream.Streams;
|
||||
|
||||
// Go reference: server/stream.go:3474-3720 (setupSourceConsumer, trySetupSourceConsumer)
|
||||
// Go reference: server/stream.go:3531 ($JS.API.CONSUMER.CREATE.{sourceName})
|
||||
// Go reference: server/stream.go:3573-3598 (DeliverPolicy, FilterSubject, AckPolicy assignment)
|
||||
|
||||
public class SourceConsumerSetupTests
|
||||
{
|
||||
// -------------------------------------------------------------------------
|
||||
// BuildConsumerCreateRequest — FilterSubject
|
||||
// Go reference: server/stream.go:3597-3598
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void BuildConsumerCreateRequest_sets_filter_subject()
|
||||
{
|
||||
var target = new MemStore();
|
||||
var coordinator = new SourceCoordinator(target, new StreamSourceConfig
|
||||
{
|
||||
Name = "SOURCE",
|
||||
FilterSubject = "orders.>",
|
||||
});
|
||||
|
||||
var req = coordinator.BuildConsumerCreateRequest();
|
||||
|
||||
req.FilterSubject.ShouldBe("orders.>");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildConsumerCreateRequest_no_filter_leaves_null()
|
||||
{
|
||||
var target = new MemStore();
|
||||
var coordinator = new SourceCoordinator(target, new StreamSourceConfig
|
||||
{
|
||||
Name = "SOURCE",
|
||||
});
|
||||
|
||||
var req = coordinator.BuildConsumerCreateRequest();
|
||||
|
||||
req.FilterSubject.ShouldBeNull();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// BuildConsumerCreateRequest — DeliverPolicy
|
||||
// Go reference: server/stream.go:3573-3582
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void BuildConsumerCreateRequest_starts_from_beginning_when_no_progress()
|
||||
{
|
||||
var target = new MemStore();
|
||||
var coordinator = new SourceCoordinator(target, new StreamSourceConfig
|
||||
{
|
||||
Name = "SOURCE",
|
||||
});
|
||||
|
||||
// LastOriginSequence is 0 — no messages have been processed yet
|
||||
coordinator.LastOriginSequence.ShouldBe(0UL);
|
||||
|
||||
var req = coordinator.BuildConsumerCreateRequest();
|
||||
|
||||
req.DeliverPolicy.ShouldBe(DeliverPolicy.All);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildConsumerCreateRequest_resumes_from_last_sequence()
|
||||
{
|
||||
var target = new MemStore();
|
||||
var coordinator = new SourceCoordinator(target, new StreamSourceConfig
|
||||
{
|
||||
Name = "SOURCE",
|
||||
});
|
||||
|
||||
// Advance LastOriginSequence by processing messages
|
||||
await coordinator.OnOriginAppendAsync(MakeMessage(3, "orders.created", "a"), default);
|
||||
await coordinator.OnOriginAppendAsync(MakeMessage(7, "orders.updated", "b"), default);
|
||||
|
||||
coordinator.LastOriginSequence.ShouldBe(7UL);
|
||||
|
||||
var req = coordinator.BuildConsumerCreateRequest();
|
||||
|
||||
req.DeliverPolicy.ShouldBe(DeliverPolicy.ByStartSequence);
|
||||
req.OptStartSeq.ShouldBe(8UL); // LastOriginSequence + 1
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// BuildConsumerCreateRequest — AckPolicy
|
||||
// Go reference: server/stream.go:3586
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void BuildConsumerCreateRequest_sets_ack_none()
|
||||
{
|
||||
var target = new MemStore();
|
||||
var coordinator = new SourceCoordinator(target, new StreamSourceConfig
|
||||
{
|
||||
Name = "SOURCE",
|
||||
});
|
||||
|
||||
var req = coordinator.BuildConsumerCreateRequest();
|
||||
|
||||
req.AckPolicy.ShouldBe(AckPolicy.None);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// BuildConsumerCreateRequest — Push + FlowControl
|
||||
// Go reference: server/stream.go:3589-3592
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void BuildConsumerCreateRequest_enables_push_and_flow_control()
|
||||
{
|
||||
var target = new MemStore();
|
||||
var coordinator = new SourceCoordinator(target, new StreamSourceConfig
|
||||
{
|
||||
Name = "SOURCE",
|
||||
});
|
||||
|
||||
var req = coordinator.BuildConsumerCreateRequest();
|
||||
|
||||
req.Push.ShouldBeTrue();
|
||||
req.FlowControl.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// BuildConsumerCreateRequest — HeartbeatMs
|
||||
// Go reference: server/stream.go:3593 — sourceHealthHB = 1 * time.Second
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void BuildConsumerCreateRequest_sets_heartbeat()
|
||||
{
|
||||
var target = new MemStore();
|
||||
var coordinator = new SourceCoordinator(target, new StreamSourceConfig
|
||||
{
|
||||
Name = "SOURCE",
|
||||
});
|
||||
|
||||
var req = coordinator.BuildConsumerCreateRequest();
|
||||
|
||||
// HeartbeatInterval is 1 second = 1000 ms
|
||||
req.HeartbeatMs.ShouldBe(1000);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// BuildConsumerCreateSubject
|
||||
// Go reference: server/stream.go:3531
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void BuildConsumerCreateSubject_formats_correctly()
|
||||
{
|
||||
var target = new MemStore();
|
||||
var coordinator = new SourceCoordinator(target, new StreamSourceConfig
|
||||
{
|
||||
Name = "MY_SOURCE",
|
||||
});
|
||||
|
||||
var subject = coordinator.BuildConsumerCreateSubject();
|
||||
|
||||
subject.ShouldBe("$JS.API.CONSUMER.CREATE.MY_SOURCE");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GetDeliverySequence
|
||||
// Go reference: server/stream.go si.dseq field
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetDeliverySequence_starts_at_zero()
|
||||
{
|
||||
var target = new MemStore();
|
||||
var coordinator = new SourceCoordinator(target, new StreamSourceConfig
|
||||
{
|
||||
Name = "SOURCE",
|
||||
});
|
||||
|
||||
coordinator.GetDeliverySequence.ShouldBe(0UL);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDeliverySequence_increments_after_processing()
|
||||
{
|
||||
var target = new MemStore();
|
||||
var coordinator = new SourceCoordinator(target, new StreamSourceConfig
|
||||
{
|
||||
Name = "SOURCE",
|
||||
});
|
||||
|
||||
await coordinator.OnOriginAppendAsync(MakeMessage(1, "orders.created", "x"), default);
|
||||
await coordinator.OnOriginAppendAsync(MakeMessage(2, "orders.updated", "y"), default);
|
||||
await coordinator.OnOriginAppendAsync(MakeMessage(3, "orders.shipped", "z"), default);
|
||||
|
||||
coordinator.GetDeliverySequence.ShouldBe(3UL);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static StoredMessage MakeMessage(ulong seq, string subject, string payload) => new()
|
||||
{
|
||||
Sequence = seq,
|
||||
Subject = subject,
|
||||
Payload = System.Text.Encoding.UTF8.GetBytes(payload),
|
||||
TimestampUtc = DateTime.UtcNow,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
using NATS.Server.JetStream.MirrorSource;
|
||||
using NATS.Server.JetStream.Models;
|
||||
using NATS.Server.JetStream.Storage;
|
||||
using Shouldly;
|
||||
|
||||
namespace NATS.Server.JetStream.Tests.JetStream.Streams;
|
||||
|
||||
// Go reference: server/stream.go:2739-2743 (mirrorInfo building StreamSourceInfo)
|
||||
// Go reference: server/stream.go:2687-2736 (sourcesInfo / StreamSourceInfo)
|
||||
|
||||
public class SourceMirrorInfoTests
|
||||
{
|
||||
// -------------------------------------------------------------------------
|
||||
// MirrorInfoResponse tests
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
// Go reference: server/stream.go:2739 — StreamSourceInfo.Name field
|
||||
public async Task MirrorInfoResponse_has_correct_name()
|
||||
{
|
||||
var target = new MemStore();
|
||||
await using var mirror = new MirrorCoordinator(target);
|
||||
|
||||
var info = mirror.GetMirrorInfo("MIRROR");
|
||||
|
||||
info.Name.ShouldBe("MIRROR");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
// Go reference: server/stream.go:2740 — StreamSourceInfo.Lag field from mirrorInfo
|
||||
public async Task MirrorInfoResponse_shows_lag()
|
||||
{
|
||||
var target = new MemStore();
|
||||
await using var mirror = new MirrorCoordinator(target);
|
||||
|
||||
// Sync up to origin seq 5, origin is at 10 → lag = 5 via GetHealthReport
|
||||
await mirror.OnOriginAppendAsync(MakeMessage(5, "a", "data"), default);
|
||||
var report = mirror.GetHealthReport(originLastSeq: 10);
|
||||
report.Lag.ShouldBe(5UL);
|
||||
|
||||
// GetMirrorInfo calls GetHealthReport() without originLastSeq, so Lag comes from
|
||||
// the internal Lag property (0 after in-process sync). Validate the field is wired up.
|
||||
var info = mirror.GetMirrorInfo("LAGGED");
|
||||
info.Lag.ShouldBe(mirror.Lag);
|
||||
info.Name.ShouldBe("LAGGED");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
// Go reference: server/stream.go:2741 — Active field, -1 when never synced
|
||||
public async Task MirrorInfoResponse_active_is_negative_when_never_synced()
|
||||
{
|
||||
var target = new MemStore();
|
||||
await using var mirror = new MirrorCoordinator(target);
|
||||
|
||||
var info = mirror.GetMirrorInfo("FRESH");
|
||||
|
||||
info.Active.ShouldBe(-1L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
// Go reference: server/stream.go:2741 — Active = ms since last sync
|
||||
public async Task MirrorInfoResponse_active_shows_ms_since_sync()
|
||||
{
|
||||
var target = new MemStore();
|
||||
await using var mirror = new MirrorCoordinator(target);
|
||||
|
||||
await mirror.OnOriginAppendAsync(MakeMessage(1, "a", "payload"), default);
|
||||
|
||||
var info = mirror.GetMirrorInfo("SYNCED");
|
||||
|
||||
info.Active.ShouldBeGreaterThanOrEqualTo(0L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
// Go reference: server/stream.go:2742 — StreamSourceInfo.Error field
|
||||
public async Task MirrorInfoResponse_includes_error()
|
||||
{
|
||||
var target = new MemStore();
|
||||
await using var mirror = new MirrorCoordinator(target);
|
||||
|
||||
mirror.SetError("consumer not found");
|
||||
|
||||
var info = mirror.GetMirrorInfo("BROKEN");
|
||||
|
||||
info.Error.ShouldBe("consumer not found");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SourceInfoResponse tests
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
// Go reference: server/stream.go:2698 — StreamSourceInfo.Name from sourceInfo
|
||||
public async Task SourceInfoResponse_has_correct_name()
|
||||
{
|
||||
var target = new MemStore();
|
||||
await using var source = new SourceCoordinator(target, new StreamSourceConfig { Name = "MY_SOURCE" });
|
||||
|
||||
var info = source.GetSourceInfo();
|
||||
|
||||
info.Name.ShouldBe("MY_SOURCE");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
// Go reference: server/stream.go:2700 — StreamSourceInfo.FilterSubject
|
||||
public async Task SourceInfoResponse_shows_filter_subject()
|
||||
{
|
||||
var target = new MemStore();
|
||||
await using var source = new SourceCoordinator(target, new StreamSourceConfig
|
||||
{
|
||||
Name = "SRC",
|
||||
FilterSubject = "orders.*",
|
||||
});
|
||||
|
||||
var info = source.GetSourceInfo();
|
||||
|
||||
info.FilterSubject.ShouldBe("orders.*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
// Go reference: server/stream.go:2701 — Active field, -1 when never synced
|
||||
public async Task SourceInfoResponse_active_is_negative_when_never_synced()
|
||||
{
|
||||
var target = new MemStore();
|
||||
await using var source = new SourceCoordinator(target, new StreamSourceConfig { Name = "SRC" });
|
||||
|
||||
var info = source.GetSourceInfo();
|
||||
|
||||
info.Active.ShouldBe(-1L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
// Go reference: server/stream.go:2701 — Active = ms since last sync
|
||||
public async Task SourceInfoResponse_active_shows_ms_since_sync()
|
||||
{
|
||||
var target = new MemStore();
|
||||
await using var source = new SourceCoordinator(target, new StreamSourceConfig { Name = "SRC" });
|
||||
|
||||
await source.OnOriginAppendAsync(MakeMessage(1, "orders.created", "payload"), default);
|
||||
|
||||
var info = source.GetSourceInfo();
|
||||
|
||||
info.Active.ShouldBeGreaterThanOrEqualTo(0L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
// Go reference: server/stream.go:2699 — StreamSourceInfo.Lag from sourceInfo
|
||||
public async Task SourceInfoResponse_lag_reflects_health_report()
|
||||
{
|
||||
var target = new MemStore();
|
||||
await using var source = new SourceCoordinator(target, new StreamSourceConfig { Name = "SRC" });
|
||||
|
||||
// Apply 3 messages directly (no background loop, no timing dependency).
|
||||
// Origin has 5 messages total; coordinator sees up to seq 3, so lag = 2.
|
||||
await source.OnOriginAppendAsync(MakeMessage(1, "a", "1"), default);
|
||||
await source.OnOriginAppendAsync(MakeMessage(2, "b", "2"), default);
|
||||
await source.OnOriginAppendAsync(MakeMessage(3, "c", "3"), default);
|
||||
|
||||
// Simulate the lag that GetHealthReport would compute against a known origin end.
|
||||
var healthReport = source.GetHealthReport(originLastSeq: 5);
|
||||
var info = source.GetSourceInfo();
|
||||
|
||||
// GetSourceInfo calls GetHealthReport() without originLastSeq, so both should
|
||||
// reflect the same internal Lag value (0 after in-process sync).
|
||||
info.Lag.ShouldBe(source.Lag);
|
||||
// Verify against the explicit-originLastSeq health report for the expected lag value.
|
||||
healthReport.Lag.ShouldBe(2UL);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helper
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static StoredMessage MakeMessage(ulong seq, string subject, string payload) => new()
|
||||
{
|
||||
Sequence = seq,
|
||||
Subject = subject,
|
||||
Payload = System.Text.Encoding.UTF8.GetBytes(payload),
|
||||
TimestampUtc = DateTime.UtcNow,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user