Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion agents/Aevatar.GAgents.Device/DeviceCommandFacades.cs
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ public EventEnvelope CreateEnvelope(DeviceCallbackDispatchCommand command, Comma
ArgumentNullException.ThrowIfNull(command.Inbound);
ArgumentNullException.ThrowIfNull(context);

// Refactor (iter1281/cluster-001-device-inbound-typed-payload): Old pattern risk was adding a second callback envelope shape.
// Refactor (issue1485/first-slice): Old pattern: adding a second callback envelope shape for typed device callbacks.
// New principle: keep the existing command facade as the only dispatch skeleton.
// DeviceInbound is packed once into the single EventEnvelope payload used by the actor system.
// The endpoint must have already rejected unknown adapter event_type values; this facade only dispatches typed inbound payloads.
Expand Down
2 changes: 1 addition & 1 deletion agents/Aevatar.GAgents.Device/DeviceEventEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ internal static DeviceInbound ParseCallbackPayload(byte[] bodyBytes)
: string.Empty;
}

// Refactor (iter1281/cluster-001-device-inbound-typed-payload): Old pattern: pass content.text through as DeviceInbound.PayloadJson.
// Refactor (issue1485/first-slice): Old pattern: pass content.text through as DeviceInbound.PayloadJson.
// New principle: terminate NyxID callback JSON at the Host/Adapter boundary.
// Known device events are allowlisted and mapped to typed Protobuf payload cases.
// Unknown or malformed content is rejected before any EventEnvelope dispatch can happen.
Expand Down
2 changes: 1 addition & 1 deletion agents/Aevatar.GAgents.Household/HouseholdEntity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ public async Task HandleDeviceInbound(DeviceInbound evt)

try
{
// Refactor (iter1281/cluster-001-device-inbound-typed-payload): Old pattern: actor parsed DeviceInbound.PayloadJson by event_type.
// Refactor (issue1485/first-slice): Old pattern: actor parsed DeviceInbound.PayloadJson by event_type.
// New principle: Host/Adapter owns JSON parsing and allowlist admission.
// HouseholdEntity consumes only typed Protobuf payload cases and ignores missing payloads.
// This keeps actor control flow independent of external callback JSON shape.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ message SpeechDeviceInboundPayload {
// Defined in Household domain because it's the inbound contract for
// HouseholdEntity; DeviceEventEndpoints (boundary layer) references
// this project, keeping dependency direction correct (Host → Domain).
// Refactor (iter1281/cluster-001-device-inbound-typed-payload): Old pattern: DeviceInbound carried payload_json across the actor boundary.
// Refactor (issue1485/first-slice): Old pattern: DeviceInbound carried payload_json across the actor boundary.
// New principle: the Host/Adapter parses NyxID callback JSON and admits only known typed device event payloads.
// The actor receives Protobuf fields, while the old payload_json tag and name stay reserved.
// Unknown adapter event_type values are rejected before dispatch instead of becoming an untyped raw payload bag.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Aevatar.CQRS.Core.Abstractions.Commands;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Builder;
Expand Down Expand Up @@ -240,6 +241,55 @@ public void DeviceInbound_contract_should_not_expose_raw_payload_bag_fields()
.Should().ContainSingle(oneof => oneof.Name == "payload");
}

[Fact]
public async Task HandleDeviceCallbackAsync_known_event_type_dispatches_typed_inbound_without_raw_payload()
{
DeviceCallbackDispatchCommand? capturedCommand = null;
var queryPort = Substitute.For<IDeviceRegistrationQueryPort>();
var callbackService = Substitute.For<IDeviceCallbackCommandService>();
queryPort.GetAsync("reg-known", Arg.Any<CancellationToken>())
.Returns(Task.FromResult<DeviceRegistrationEntry?>(MakeRegistration()));
callbackService.DispatchCallbackAsync(
Arg.Do<DeviceCallbackDispatchCommand>(command => capturedCommand = command),
Arg.Any<CancellationToken>())
.Returns(Task.FromResult(
CommandDispatchResult<DeviceCommandAcceptedReceipt, DeviceCallbackCommandStartError>.Success(
new DeviceCommandAcceptedReceipt(
"household-reg-known",
"cmd-known",
"corr-known",
"reg-known"))));

var innerEvent = JsonSerializer.Serialize(new
{
event_id = "evt-known",
source = "temperature-sensor",
event_type = "temperature_change",
temperature = 23.5,
humidity = 44.0,
light_level = 18.0,
});
var context = CreateJsonHttpContext(EncodeCallbackPayload(innerEvent, senderPlatformId: "sensor-1"));

var result = await InvokeHandleDeviceCallbackAsync(
context,
"reg-known",
queryPort,
callbackService);
var (statusCode, body) = await ExecuteResultAsync(result);

statusCode.Should().Be(StatusCodes.Status202Accepted);
body.Should().Contain("accepted");
capturedCommand.Should().NotBeNull();
capturedCommand!.RegistrationId.Should().Be("reg-known");
capturedCommand.Inbound.DeviceId.Should().Be("sensor-1");
capturedCommand.Inbound.PayloadCase.Should().Be(Aevatar.GAgents.Household.DeviceInbound.PayloadOneofCase.Sensor);
capturedCommand.Inbound.Sensor.Temperature.Should().Be(23.5);
Aevatar.GAgents.Household.DeviceInbound.Descriptor.FindFieldByName("payload_json").Should().BeNull();
await callbackService.Received(1)
.DispatchCallbackAsync(Arg.Any<DeviceCallbackDispatchCommand>(), Arg.Any<CancellationToken>());
}

[Fact]
public async Task HandleDeviceCallbackAsync_unknown_event_type_returns_reject_status_and_does_not_dispatch()
{
Expand Down Expand Up @@ -335,6 +385,27 @@ public void ParseCallbackPayload_malformed_typed_payload_throws_at_adapter_bound
act.Should().Throw<Exception>();
}

[Fact]
public void DeviceCallbackCommandFacade_ShouldPackDeviceInboundOnceInSingleEventEnvelopePayload()
{
var sourcePath = Path.GetFullPath(Path.Combine(
AppContext.BaseDirectory,
"../../../../../agents/Aevatar.GAgents.Device/DeviceCommandFacades.cs"));
var source = File.ReadAllText(sourcePath);
var callbackFactoryStart = source.IndexOf("internal sealed class DeviceCallbackCommandEnvelopeFactory", StringComparison.Ordinal);
callbackFactoryStart.Should().BeGreaterThanOrEqualTo(0);
var callbackReceiptFactoryStart = source.IndexOf("internal sealed class DeviceCallbackCommandReceiptFactory", callbackFactoryStart, StringComparison.Ordinal);
callbackReceiptFactoryStart.Should().BeGreaterThan(callbackFactoryStart);
var callbackFactorySource = source[callbackFactoryStart..callbackReceiptFactoryStart];

callbackFactorySource.Should().Contain("new EventEnvelope");
callbackFactorySource.Should().Contain("Payload = Any.Pack(command.Inbound)");
callbackFactorySource.Should().NotContain("Payload = Any.Pack(command)");
callbackFactorySource.Should().NotContain("new DeviceCallbackEnvelope");
callbackFactorySource.Should().Contain("EnvelopeRouteSemantics.CreateDirect(PublisherActorId, context.TargetId)");
callbackFactorySource.Should().Contain("Refactor (issue1485/first-slice): Old pattern:");
}

// ─── HMAC Verification Tests ───

private static DeviceRegistrationEntry MakeRegistration(string hmacKey = "test-secret") => new()
Expand Down
Loading