feat(S2-b): connect durable MQTT sync to capture workflow
This commit is contained in:
61
tests/FieldLogger.Sync.Tests/FakeMqttTransport.cs
Normal file
61
tests/FieldLogger.Sync.Tests/FakeMqttTransport.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using FieldLogger.Sync;
|
||||
|
||||
namespace FieldLogger.Sync.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory stand-in for the broker. Records published payloads, lets a test toggle
|
||||
/// connectivity, force publish failures, and inject application-level acks — so the engine's
|
||||
/// durability/ack/backoff behaviour is exercised without MQTTnet or a live broker.
|
||||
/// </summary>
|
||||
public sealed class FakeMqttTransport : IMqttTransport
|
||||
{
|
||||
public bool IsConnected { get; set; } = true;
|
||||
public bool FailNextPublish { get; set; }
|
||||
public List<(string Topic, byte[] Payload)> Published { get; } = new();
|
||||
|
||||
public event Func<AckBatch, Task>? AckReceived;
|
||||
|
||||
public Task ConnectAsync(string ackTopic, CancellationToken ct = default)
|
||||
{
|
||||
IsConnected = true;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DisconnectAsync()
|
||||
{
|
||||
IsConnected = false;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task PublishAsync(string topic, byte[] payload, string schemaVersion, CancellationToken ct = default)
|
||||
{
|
||||
if (!IsConnected) throw new InvalidOperationException("not connected");
|
||||
if (FailNextPublish)
|
||||
{
|
||||
FailNextPublish = false;
|
||||
throw new InvalidOperationException("simulated broker publish failure");
|
||||
}
|
||||
Published.Add((topic, payload));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Simulate the cloud emitting an application-level ack for these pointIds.</summary>
|
||||
public async Task InjectAckAsync(params AckItem[] items)
|
||||
{
|
||||
if (AckReceived is { } handlers)
|
||||
{
|
||||
var batch = new AckBatch { Results = items.ToList() };
|
||||
foreach (Func<AckBatch, Task> handler in handlers.GetInvocationList())
|
||||
await handler(batch);
|
||||
}
|
||||
}
|
||||
|
||||
public Task InjectAcceptAsync(string pointId) =>
|
||||
InjectAckAsync(new AckItem { PointId = pointId, Outcome = AckOutcome.ACCEPTED });
|
||||
|
||||
public Task InjectDuplicateAsync(string pointId) =>
|
||||
InjectAckAsync(new AckItem { PointId = pointId, Outcome = AckOutcome.DUPLICATE });
|
||||
|
||||
public Task InjectRejectAsync(string pointId, string reasonCode) =>
|
||||
InjectAckAsync(new AckItem { PointId = pointId, Outcome = AckOutcome.REJECTED, ReasonCode = reasonCode });
|
||||
}
|
||||
35
tests/FieldLogger.Sync.Tests/FieldLogger.Sync.Tests.csproj
Normal file
35
tests/FieldLogger.Sync.Tests/FieldLogger.Sync.Tests.csproj
Normal file
@@ -0,0 +1,35 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Headless tests for the cloud-sync core (S2-b/S2-c): durable-queue restart survival,
|
||||
ack-release, exactly-once, backoff, broker-loss-safety, and LOG-7 no-silent-discard.
|
||||
Plain net9.0 + xUnit so it runs in the QA gate (auto-discovered *.Tests.csproj) without
|
||||
MAUI workloads or a live broker (uses a fake IMqttTransport).
|
||||
Run: dotnet test tests/FieldLogger.Sync.Tests/FieldLogger.Sync.Tests.csproj
|
||||
-->
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\FieldLogger.Sync\FieldLogger.Sync.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="..\..\..\meta\contracts\fixtures\app-log-*.json"
|
||||
Link="ContractFixtures\%(Filename)%(Extension)"
|
||||
CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
173
tests/FieldLogger.Sync.Tests/SyncEngineTests.cs
Normal file
173
tests/FieldLogger.Sync.Tests/SyncEngineTests.cs
Normal file
@@ -0,0 +1,173 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using FieldLogger.Sync;
|
||||
using Xunit;
|
||||
|
||||
namespace FieldLogger.Sync.Tests;
|
||||
|
||||
public sealed class SyncEngineTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = DateTimeOffset.Parse("2026-08-21T10:00:00Z");
|
||||
|
||||
static SyncEngineTests() => SQLitePCL.Batteries_V2.Init();
|
||||
|
||||
[Fact]
|
||||
public void Point_serializes_to_the_frozen_singleton_batch()
|
||||
{
|
||||
var point = MakePoint("018f1a00-0000-7000-8000-000000000001");
|
||||
using var json = JsonDocument.Parse(point.ToAppLogPayloadUtf8(jobId: "job_1"));
|
||||
|
||||
var root = json.RootElement;
|
||||
Assert.Equal("1", root.GetProperty("schemaVersion").GetString());
|
||||
Assert.Equal("job_1", root.GetProperty("jobId").GetString());
|
||||
var wire = Assert.Single(root.GetProperty("points").EnumerateArray());
|
||||
Assert.Equal(point.PointId, wire.GetProperty("pointId").GetString());
|
||||
Assert.Equal("APP", wire.GetProperty("origin").GetString());
|
||||
Assert.Equal("APP_MQTT", wire.GetProperty("uploadPath").GetString());
|
||||
Assert.Equal("FIXED", wire.GetProperty("fix").GetString());
|
||||
Assert.Equal(-80.2, wire.GetProperty("lng").GetDouble());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void App_serializer_matches_the_shared_contract_fixture()
|
||||
{
|
||||
JsonNode? actual = JsonNode.Parse(MakePoint("018f1a00-0000-7000-8000-000000000001")
|
||||
.ToAppLogPayloadUtf8(jobId: "job_1"));
|
||||
string path = Path.Combine(AppContext.BaseDirectory, "ContractFixtures", "app-log-points-v1.json");
|
||||
JsonNode? expected = JsonNode.Parse(File.ReadAllText(path));
|
||||
|
||||
Assert.True(JsonNode.DeepEquals(expected, actual));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Invalid_capture_is_refused_with_a_reason_code()
|
||||
{
|
||||
var point = MakePoint("018f1a00-0000-7000-8000-000000000002") with { Position = null };
|
||||
var error = Assert.Throws<PointNotPublishableException>(() => point.ToAppLogPayloadUtf8(jobId: "job_1"));
|
||||
Assert.Equal("POSITION_MISSING", error.ReasonCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Queue_survives_restart_and_releases_only_after_acceptance()
|
||||
{
|
||||
string db = NewDbPath();
|
||||
try
|
||||
{
|
||||
var firstStore = new SqliteOutboundStore(db);
|
||||
await firstStore.InitAsync();
|
||||
var message = OutboundMessage.FromPoint(
|
||||
MakePoint("018f1a00-0000-7000-8000-000000000003"),
|
||||
"ul/org_alpha/app/client_1/log/points", Now, jobId: "job_1");
|
||||
Assert.True(await firstStore.EnqueueAsync(message));
|
||||
|
||||
var firstTransport = new FakeMqttTransport();
|
||||
var firstEngine = Engine(firstTransport, firstStore);
|
||||
Assert.Equal(1, await firstEngine.DrainOnceAsync());
|
||||
Assert.Equal(1, await firstStore.PendingCountAsync()); // broker PUBACK is not enough
|
||||
|
||||
var restartedStore = new SqliteOutboundStore(db);
|
||||
await restartedStore.InitAsync(); // resets interrupted in-flight work to pending
|
||||
var restartedTransport = new FakeMqttTransport();
|
||||
var restartedEngine = Engine(restartedTransport, restartedStore);
|
||||
Assert.Equal(1, await restartedEngine.DrainOnceAsync());
|
||||
await restartedTransport.InjectAcceptAsync(message.PointId);
|
||||
|
||||
Assert.Equal(0, await restartedStore.PendingCountAsync());
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteDb(db);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Duplicate_ack_is_safe_to_release_and_publish_failure_is_not()
|
||||
{
|
||||
string db = NewDbPath();
|
||||
try
|
||||
{
|
||||
var store = new SqliteOutboundStore(db);
|
||||
await store.InitAsync();
|
||||
var message = OutboundMessage.FromPoint(
|
||||
MakePoint("018f1a00-0000-7000-8000-000000000004"),
|
||||
"ul/org_alpha/app/client_1/log/points", Now, jobId: "job_1");
|
||||
Assert.True(await store.EnqueueAsync(message));
|
||||
|
||||
var transport = new FakeMqttTransport { FailNextPublish = true };
|
||||
var engine = Engine(transport, store);
|
||||
Assert.Equal(0, await engine.DrainOnceAsync());
|
||||
Assert.Equal(1, await store.PendingCountAsync());
|
||||
|
||||
await transport.InjectDuplicateAsync(message.PointId);
|
||||
Assert.Equal(0, await store.PendingCountAsync());
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteDb(db);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Terminal_rejection_is_retained_and_surfaced()
|
||||
{
|
||||
string db = NewDbPath();
|
||||
try
|
||||
{
|
||||
var store = new SqliteOutboundStore(db);
|
||||
await store.InitAsync();
|
||||
var message = OutboundMessage.FromPoint(
|
||||
MakePoint("018f1a00-0000-7000-8000-000000000005"),
|
||||
"ul/org_alpha/app/client_1/log/points", Now, jobId: "job_1");
|
||||
await store.EnqueueAsync(message);
|
||||
var transport = new FakeMqttTransport();
|
||||
var engine = Engine(transport, store);
|
||||
OutboundMessage? surfaced = null;
|
||||
engine.PointRejected += value =>
|
||||
{
|
||||
surfaced = value;
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
await transport.InjectRejectAsync(message.PointId, "VALIDATION_ERROR");
|
||||
|
||||
Assert.Equal(0, await store.PendingCountAsync());
|
||||
Assert.Equal(message.PointId, surfaced?.PointId);
|
||||
Assert.Equal("VALIDATION_ERROR", Assert.Single(await store.GetRejectedAsync()).LastReason);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteDb(db);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ack_parser_uses_results_and_outcome()
|
||||
{
|
||||
var ack = AckBatch.FromJsonUtf8(
|
||||
"""{"schemaVersion":"1","results":[{"pointId":"018f1a00-0000-7000-8000-000000000006","outcome":"DUPLICATE"}]}"""u8);
|
||||
Assert.Equal(AckOutcome.DUPLICATE, Assert.Single(ack.Results).Outcome);
|
||||
}
|
||||
|
||||
private static MqttSyncEngine Engine(FakeMqttTransport transport, IOutboundStore store) =>
|
||||
new(transport, store, new SyncOptions { OrgId = "org_alpha", ClientId = "client_1" },
|
||||
() => Now, new BackoffPolicy(TimeSpan.FromSeconds(1), TimeSpan.FromMinutes(1), new Random(1)));
|
||||
|
||||
private static PointRecord MakePoint(string id) => new()
|
||||
{
|
||||
PointId = id,
|
||||
CreatedAt = Now,
|
||||
Position = new PositionGroup { Lat = 40.1, Lon = -80.2, PositionEpoch = Now.AddSeconds(-1) },
|
||||
Gnss = new GnssGroup { FixType = "RTK_FIXED", SatsUsed = 18, Hrms = 0.02, Vrms = 0.04 },
|
||||
Attributes = new AttributesGroup { UtilityType = "WATER" },
|
||||
};
|
||||
|
||||
private static string NewDbPath() => Path.Combine(Path.GetTempPath(), $"fieldlogger-sync-{Guid.NewGuid():N}.db3");
|
||||
|
||||
private static void DeleteDb(string db)
|
||||
{
|
||||
foreach (string path in new[] { db, $"{db}-shm", $"{db}-wal" })
|
||||
{
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user