From 108a3d3f8a0f8130b6f8ee5a493b6783f55d3d2e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 19:11:04 -0400 Subject: [PATCH] Add client behavior fixtures --- .../behavior/auth/auth-error-cases.json | 36 ++ .../command-replies/register.ok.reply.json | 30 ++ .../write.mxaccess-failure.reply.json | 38 ++ .../event-streams/session-event-stream.json | 159 ++++++++ clients/proto/fixtures/behavior/manifest.json | 59 +++ .../statuses/status-conversion-cases.json | 41 ++ .../timeout-cancel/timeout-cancel-cases.json | 27 ++ .../values/value-conversion-cases.json | 85 ++++ clients/proto/proto-inputs.json | 1 + docs/ClientBehaviorFixtures.md | 106 +++++ docs/client-libraries-design.md | 6 + docs/client-proto-generation.md | 22 + scripts/validate-client-behavior-fixtures.ps1 | 26 ++ .../Contracts/ClientBehaviorFixtureTests.cs | 379 ++++++++++++++++++ 14 files changed, 1015 insertions(+) create mode 100644 clients/proto/fixtures/behavior/auth/auth-error-cases.json create mode 100644 clients/proto/fixtures/behavior/command-replies/register.ok.reply.json create mode 100644 clients/proto/fixtures/behavior/command-replies/write.mxaccess-failure.reply.json create mode 100644 clients/proto/fixtures/behavior/event-streams/session-event-stream.json create mode 100644 clients/proto/fixtures/behavior/manifest.json create mode 100644 clients/proto/fixtures/behavior/statuses/status-conversion-cases.json create mode 100644 clients/proto/fixtures/behavior/timeout-cancel/timeout-cancel-cases.json create mode 100644 clients/proto/fixtures/behavior/values/value-conversion-cases.json create mode 100644 docs/ClientBehaviorFixtures.md create mode 100644 scripts/validate-client-behavior-fixtures.ps1 create mode 100644 src/MxGateway.Tests/Contracts/ClientBehaviorFixtureTests.cs diff --git a/clients/proto/fixtures/behavior/auth/auth-error-cases.json b/clients/proto/fixtures/behavior/auth/auth-error-cases.json new file mode 100644 index 0000000..804edbf --- /dev/null +++ b/clients/proto/fixtures/behavior/auth/auth-error-cases.json @@ -0,0 +1,36 @@ +{ + "schemaVersion": 1, + "cases": [ + { + "id": "missing-api-key", + "grpcStatusCode": "UNAUTHENTICATED", + "clientErrorCategory": "AuthenticationError", + "inputMetadata": { + "authorization": "" + }, + "expectedRedactedOutput": "authentication failed: missing bearer token", + "retryableWithoutCredentialChange": false + }, + { + "id": "invalid-api-key", + "grpcStatusCode": "UNAUTHENTICATED", + "clientErrorCategory": "AuthenticationError", + "inputMetadata": { + "authorization": "Bearer " + }, + "expectedRedactedOutput": "authentication failed: invalid API key ", + "retryableWithoutCredentialChange": false + }, + { + "id": "missing-write-scope", + "grpcStatusCode": "PERMISSION_DENIED", + "clientErrorCategory": "AuthorizationError", + "inputMetadata": { + "authorization": "Bearer " + }, + "requiredScope": "mxaccess.write", + "expectedRedactedOutput": "authorization failed: missing scope mxaccess.write", + "retryableWithoutCredentialChange": false + } + ] +} diff --git a/clients/proto/fixtures/behavior/command-replies/register.ok.reply.json b/clients/proto/fixtures/behavior/command-replies/register.ok.reply.json new file mode 100644 index 0000000..c083db9 --- /dev/null +++ b/clients/proto/fixtures/behavior/command-replies/register.ok.reply.json @@ -0,0 +1,30 @@ +{ + "sessionId": "session-fixture", + "correlationId": "gateway-correlation-register-1", + "kind": "MX_COMMAND_KIND_REGISTER", + "protocolStatus": { + "code": "PROTOCOL_STATUS_CODE_OK", + "message": "Register completed." + }, + "hresult": 0, + "returnValue": { + "dataType": "MX_DATA_TYPE_INTEGER", + "variantType": "VT_I4", + "int32Value": 12 + }, + "statuses": [ + { + "success": 1, + "category": "MX_STATUS_CATEGORY_OK", + "detectedBy": "MX_STATUS_SOURCE_RESPONDING_LMX", + "detail": 0, + "rawCategory": 0, + "rawDetectedBy": 0, + "diagnosticText": "OK" + } + ], + "diagnosticMessage": "COM Register returned server handle 12.", + "register": { + "serverHandle": 12 + } +} diff --git a/clients/proto/fixtures/behavior/command-replies/write.mxaccess-failure.reply.json b/clients/proto/fixtures/behavior/command-replies/write.mxaccess-failure.reply.json new file mode 100644 index 0000000..d57099b --- /dev/null +++ b/clients/proto/fixtures/behavior/command-replies/write.mxaccess-failure.reply.json @@ -0,0 +1,38 @@ +{ + "sessionId": "session-fixture", + "correlationId": "gateway-correlation-write-1", + "kind": "MX_COMMAND_KIND_WRITE", + "protocolStatus": { + "code": "PROTOCOL_STATUS_CODE_MXACCESS_FAILURE", + "message": "MXAccess rejected the write." + }, + "hresult": -2147220992, + "returnValue": { + "dataType": "MX_DATA_TYPE_NO_DATA", + "variantType": "VT_EMPTY", + "isNull": true, + "rawDiagnostic": "MXAccess returned no value for the failed write.", + "rawDataType": 2 + }, + "statuses": [ + { + "success": 0, + "category": "MX_STATUS_CATEGORY_SECURITY_ERROR", + "detectedBy": "MX_STATUS_SOURCE_RESPONDING_LMX", + "detail": 321, + "rawCategory": 8, + "rawDetectedBy": 3, + "diagnosticText": "Write denied by provider security." + }, + { + "success": 0, + "category": "MX_STATUS_CATEGORY_OPERATIONAL_ERROR", + "detectedBy": "MX_STATUS_SOURCE_RESPONDING_NMX", + "detail": 902, + "rawCategory": 7, + "rawDetectedBy": 5, + "diagnosticText": "Provider rejected the item state." + } + ], + "diagnosticMessage": "Fixture preserves a data-bearing MXAccess failure reply with HRESULT and status array." +} diff --git a/clients/proto/fixtures/behavior/event-streams/session-event-stream.json b/clients/proto/fixtures/behavior/event-streams/session-event-stream.json new file mode 100644 index 0000000..82fdad3 --- /dev/null +++ b/clients/proto/fixtures/behavior/event-streams/session-event-stream.json @@ -0,0 +1,159 @@ +{ + "sessionId": "session-fixture", + "description": "Ordered event stream sample for one worker-backed session.", + "events": [ + { + "family": "MX_EVENT_FAMILY_ON_DATA_CHANGE", + "sessionId": "session-fixture", + "serverHandle": 12, + "itemHandle": 34, + "value": { + "dataType": "MX_DATA_TYPE_INTEGER", + "variantType": "VT_I4", + "int32Value": 123 + }, + "quality": 192, + "sourceTimestamp": "2026-01-01T00:00:00Z", + "statuses": [ + { + "success": 1, + "category": "MX_STATUS_CATEGORY_OK", + "detectedBy": "MX_STATUS_SOURCE_RESPONDING_LMX", + "detail": 0, + "rawCategory": 0, + "rawDetectedBy": 0, + "diagnosticText": "OK" + } + ], + "workerSequence": "1", + "workerTimestamp": "2026-01-01T00:00:00.010Z", + "gatewayReceiveTimestamp": "2026-01-01T00:00:00.015Z", + "onDataChange": {} + }, + { + "family": "MX_EVENT_FAMILY_ON_WRITE_COMPLETE", + "sessionId": "session-fixture", + "serverHandle": 12, + "itemHandle": 34, + "value": { + "dataType": "MX_DATA_TYPE_DOUBLE", + "variantType": "VT_R8", + "doubleValue": 45.5 + }, + "quality": 192, + "sourceTimestamp": "2026-01-01T00:00:01Z", + "statuses": [ + { + "success": 1, + "category": "MX_STATUS_CATEGORY_OK", + "detectedBy": "MX_STATUS_SOURCE_RESPONDING_LMX", + "detail": 0, + "rawCategory": 0, + "rawDetectedBy": 0, + "diagnosticText": "Write complete." + } + ], + "workerSequence": "2", + "workerTimestamp": "2026-01-01T00:00:01.010Z", + "gatewayReceiveTimestamp": "2026-01-01T00:00:01.015Z", + "hresult": 0, + "onWriteComplete": {} + }, + { + "family": "MX_EVENT_FAMILY_OPERATION_COMPLETE", + "sessionId": "session-fixture", + "serverHandle": 12, + "itemHandle": 34, + "value": { + "dataType": "MX_DATA_TYPE_STRING", + "variantType": "VT_BSTR", + "stringValue": "operation-complete" + }, + "quality": 192, + "sourceTimestamp": "2026-01-01T00:00:02Z", + "statuses": [ + { + "success": 1, + "category": "MX_STATUS_CATEGORY_OK", + "detectedBy": "MX_STATUS_SOURCE_RESPONDING_NMX", + "detail": 0, + "rawCategory": 0, + "rawDetectedBy": 0, + "diagnosticText": "Operation complete." + } + ], + "workerSequence": "3", + "workerTimestamp": "2026-01-01T00:00:02.010Z", + "gatewayReceiveTimestamp": "2026-01-01T00:00:02.015Z", + "operationComplete": {} + }, + { + "family": "MX_EVENT_FAMILY_ON_BUFFERED_DATA_CHANGE", + "sessionId": "session-fixture", + "serverHandle": 12, + "itemHandle": 34, + "value": { + "dataType": "MX_DATA_TYPE_FLOAT", + "arrayValue": { + "elementDataType": "MX_DATA_TYPE_FLOAT", + "variantType": "VT_ARRAY|VT_R4", + "dimensions": [ + 2 + ], + "floatValues": { + "values": [ + 1.5, + 2.5 + ] + } + } + }, + "quality": 192, + "sourceTimestamp": "2026-01-01T00:00:03Z", + "statuses": [ + { + "success": 1, + "category": "MX_STATUS_CATEGORY_OK", + "detectedBy": "MX_STATUS_SOURCE_RESPONDING_LMX", + "detail": 0, + "rawCategory": 0, + "rawDetectedBy": 0, + "diagnosticText": "Buffered data delivered." + } + ], + "workerSequence": "4", + "workerTimestamp": "2026-01-01T00:00:03.010Z", + "gatewayReceiveTimestamp": "2026-01-01T00:00:03.015Z", + "onBufferedDataChange": { + "dataType": "MX_DATA_TYPE_FLOAT", + "qualityValues": { + "elementDataType": "MX_DATA_TYPE_INTEGER", + "variantType": "VT_ARRAY|VT_I4", + "dimensions": [ + 2 + ], + "int32Values": { + "values": [ + 192, + 192 + ] + } + }, + "timestampValues": { + "elementDataType": "MX_DATA_TYPE_TIME", + "variantType": "VT_ARRAY|VT_DATE", + "dimensions": [ + 2 + ], + "timestampValues": { + "values": [ + "2026-01-01T00:00:02Z", + "2026-01-01T00:00:03Z" + ] + } + }, + "rawDataType": 5 + } + } + ] +} diff --git a/clients/proto/fixtures/behavior/manifest.json b/clients/proto/fixtures/behavior/manifest.json new file mode 100644 index 0000000..7585f94 --- /dev/null +++ b/clients/proto/fixtures/behavior/manifest.json @@ -0,0 +1,59 @@ +{ + "schemaVersion": 1, + "fixtureSet": "mxaccess-gateway-client-behavior", + "contractName": "mxaccess-gateway", + "gatewayProtocolVersion": 1, + "workerProtocolVersion": 1, + "protoInputManifest": "clients/proto/proto-inputs.json", + "fixtures": [ + { + "id": "command-reply.register.ok", + "category": "command_replies", + "messageType": "mxaccess_gateway.v1.MxCommandReply", + "path": "command-replies/register.ok.reply.json", + "expectation": "Successful command replies preserve protocol status, HRESULT, return value, status arrays, and method-specific output." + }, + { + "id": "command-reply.write.mxaccess-failure", + "category": "command_replies", + "messageType": "mxaccess_gateway.v1.MxCommandReply", + "path": "command-replies/write.mxaccess-failure.reply.json", + "expectation": "MXAccess failures are data-bearing replies with HRESULT and status details, not transport failures." + }, + { + "id": "event-stream.session-ordered", + "category": "event_streams", + "messageType": "mxaccess_gateway.v1.MxEvent", + "path": "event-streams/session-event-stream.json", + "expectation": "Clients preserve per-session event order and event family bodies exactly as emitted." + }, + { + "id": "values.conversion-cases", + "category": "value_conversion", + "messageType": "mxaccess_gateway.v1.MxValue", + "path": "values/value-conversion-cases.json", + "expectation": "Clients expose typed projections and keep raw fallback metadata when conversion is incomplete." + }, + { + "id": "statuses.conversion-cases", + "category": "status_conversion", + "messageType": "mxaccess_gateway.v1.MxStatusProxy", + "path": "statuses/status-conversion-cases.json", + "expectation": "Clients preserve every MXSTATUS_PROXY field, including raw category/source values." + }, + { + "id": "auth.error-cases", + "category": "auth_errors", + "messageType": "client_behavior.v1.AuthErrorCase", + "path": "auth/auth-error-cases.json", + "expectation": "Clients map authentication and authorization failures distinctly and redact credentials." + }, + { + "id": "timeout-cancel.expected-behavior", + "category": "timeout_cancel", + "messageType": "client_behavior.v1.TimeoutCancelCase", + "path": "timeout-cancel/timeout-cancel-cases.json", + "expectation": "Client cancellation stops waiting locally but does not imply an in-flight MXAccess COM call was aborted." + } + ] +} diff --git a/clients/proto/fixtures/behavior/statuses/status-conversion-cases.json b/clients/proto/fixtures/behavior/statuses/status-conversion-cases.json new file mode 100644 index 0000000..4219463 --- /dev/null +++ b/clients/proto/fixtures/behavior/statuses/status-conversion-cases.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": 1, + "cases": [ + { + "id": "ok.responding-lmx", + "status": { + "success": 1, + "category": "MX_STATUS_CATEGORY_OK", + "detectedBy": "MX_STATUS_SOURCE_RESPONDING_LMX", + "detail": 0, + "rawCategory": 0, + "rawDetectedBy": 0, + "diagnosticText": "OK" + } + }, + { + "id": "security-error.requesting-lmx", + "status": { + "success": 0, + "category": "MX_STATUS_CATEGORY_SECURITY_ERROR", + "detectedBy": "MX_STATUS_SOURCE_REQUESTING_LMX", + "detail": 401, + "rawCategory": 8, + "rawDetectedBy": 2, + "diagnosticText": "Requesting LMX denied the secured operation." + } + }, + { + "id": "raw-unknown-category", + "status": { + "success": 0, + "category": "MX_STATUS_CATEGORY_UNKNOWN", + "detectedBy": "MX_STATUS_SOURCE_UNKNOWN", + "detail": 65535, + "rawCategory": 99, + "rawDetectedBy": 77, + "diagnosticText": "Unknown native MXSTATUS_PROXY fields are preserved." + } + } + ] +} diff --git a/clients/proto/fixtures/behavior/timeout-cancel/timeout-cancel-cases.json b/clients/proto/fixtures/behavior/timeout-cancel/timeout-cancel-cases.json new file mode 100644 index 0000000..5e816a1 --- /dev/null +++ b/clients/proto/fixtures/behavior/timeout-cancel/timeout-cancel-cases.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": 1, + "cases": [ + { + "id": "unary-deadline-exceeded", + "operation": "Invoke", + "clientDeadline": "2s", + "grpcStatusCode": "DEADLINE_EXCEEDED", + "clientErrorCategory": "TimeoutError", + "gatewayWaitBehavior": "stops_waiting_for_reply", + "workerCommandBehavior": "continues_until_worker_reply_or_worker_fault", + "sessionExpectation": "session_state_is_unknown_until_follow_up_status_or_close", + "expectedClientAction": "issue GetSessionState or CloseSession before reusing handles" + }, + { + "id": "stream-cancel", + "operation": "StreamEvents", + "clientDeadline": "5s", + "grpcStatusCode": "CANCELLED", + "clientErrorCategory": "CancelledError", + "gatewayWaitBehavior": "stops_streaming_to_that_call", + "workerCommandBehavior": "does_not_cancel_worker_session", + "sessionExpectation": "session_remains_ready_if_worker_stays_healthy", + "expectedClientAction": "open a new StreamEvents call with the last observed worker sequence" + } + ] +} diff --git a/clients/proto/fixtures/behavior/values/value-conversion-cases.json b/clients/proto/fixtures/behavior/values/value-conversion-cases.json new file mode 100644 index 0000000..8fbabe2 --- /dev/null +++ b/clients/proto/fixtures/behavior/values/value-conversion-cases.json @@ -0,0 +1,85 @@ +{ + "schemaVersion": 1, + "cases": [ + { + "id": "bool.true", + "expectedKind": "boolValue", + "value": { + "dataType": "MX_DATA_TYPE_BOOLEAN", + "variantType": "VT_BOOL", + "boolValue": true + } + }, + { + "id": "int64.large", + "expectedKind": "int64Value", + "value": { + "dataType": "MX_DATA_TYPE_INTEGER", + "variantType": "VT_I8", + "int64Value": "9223372036854770000" + } + }, + { + "id": "timestamp.utc", + "expectedKind": "timestampValue", + "value": { + "dataType": "MX_DATA_TYPE_TIME", + "variantType": "VT_DATE", + "timestampValue": "2026-01-01T00:00:04Z" + } + }, + { + "id": "string-array", + "expectedKind": "arrayValue", + "value": { + "dataType": "MX_DATA_TYPE_STRING", + "arrayValue": { + "elementDataType": "MX_DATA_TYPE_STRING", + "variantType": "VT_ARRAY|VT_BSTR", + "dimensions": [ + 2 + ], + "stringValues": { + "values": [ + "alpha", + "beta" + ] + } + } + } + }, + { + "id": "raw-fallback.variant", + "expectedKind": "rawValue", + "value": { + "dataType": "MX_DATA_TYPE_UNKNOWN", + "variantType": "VT_RECORD", + "rawDiagnostic": "No lossless typed projection exists for this VARIANT.", + "rawDataType": 32767, + "rawValue": "AQIDBAU=" + } + }, + { + "id": "raw-array-fallback", + "expectedKind": "arrayValue", + "value": { + "dataType": "MX_DATA_TYPE_UNKNOWN", + "arrayValue": { + "elementDataType": "MX_DATA_TYPE_UNKNOWN", + "variantType": "VT_ARRAY|VT_VARIANT", + "dimensions": [ + 2 + ], + "rawDiagnostic": "Array elements contain mixed VARIANT types.", + "rawElementDataType": 32767, + "rawValues": { + "values": [ + "AAE=", + "AgM=" + ] + } + } + } + } + ] +} diff --git a/clients/proto/proto-inputs.json b/clients/proto/proto-inputs.json index 3d1f75e..0042f94 100644 --- a/clients/proto/proto-inputs.json +++ b/clients/proto/proto-inputs.json @@ -16,6 +16,7 @@ ], "descriptorSet": "clients/proto/descriptors/mxaccessgw-client-v1.protoset", "fixtureRoot": "clients/proto/fixtures/golden", + "behaviorFixtureRoot": "clients/proto/fixtures/behavior", "generatedOutputs": { "dotnet": "clients/dotnet/generated", "go": "clients/go/internal/generated", diff --git a/docs/ClientBehaviorFixtures.md b/docs/ClientBehaviorFixtures.md new file mode 100644 index 0000000..e3765e4 --- /dev/null +++ b/docs/ClientBehaviorFixtures.md @@ -0,0 +1,106 @@ +# Client Behavior Fixtures + +Client behavior fixtures define the shared expectations used by the official +.NET, Go, Rust, Python, and Java clients. They keep wrapper behavior aligned +while each language exposes idiomatic APIs over the same protobuf contract. + +## Fixture Set + +The fixture manifest is `clients/proto/fixtures/behavior/manifest.json`. +`clients/proto/proto-inputs.json` references the fixture root through +`behaviorFixtureRoot` so generators and client test projects can discover the +same files they use for descriptor inputs. + +The fixture set contains: + +- command reply protobuf JSON, +- ordered event stream protobuf JSON samples, +- `MxValue` conversion case sets, +- `MxStatusProxy` conversion case sets, +- authentication and authorization error expectations, +- timeout and cancellation behavior expectations. + +Protobuf message fixtures use protobuf JSON field names and enum values. Files +that describe client wrapper behavior use explicit JSON fields instead of a +proto message because those expectations apply above the generated transport +types. + +## Command Replies + +Command reply fixtures live in +`clients/proto/fixtures/behavior/command-replies/`. They parse as +`mxaccess_gateway.v1.MxCommandReply`. + +Clients use these fixtures to verify that successful and failed MXAccess +commands both carry the full reply details: + +- `protocolStatus`, +- `hresult`, +- `returnValue`, +- repeated `statuses`, +- method-specific reply payloads when MXAccess returns out parameters. + +MXAccess failures remain command replies when the gateway reached the worker and +the worker captured HRESULT or `MXSTATUS_PROXY` details. Client wrappers should +map those replies to rich command errors without discarding the raw reply. + +## Event Streams + +Event stream fixtures live in +`clients/proto/fixtures/behavior/event-streams/`. Each file contains an ordered +`events` array whose entries parse as `mxaccess_gateway.v1.MxEvent`. + +Clients use these fixtures to verify that stream helpers preserve +`workerSequence` order and expose each native event family: + +- `OnDataChange`, +- `OnWriteComplete`, +- `OperationComplete`, +- `OnBufferedDataChange`. + +Wrappers must not reorder, coalesce, or drop events while reading the fixture. + +## Value And Status Conversion + +Value fixtures live in `clients/proto/fixtures/behavior/values/`. Each case +contains a `value` object that parses as `mxaccess_gateway.v1.MxValue`. + +Status fixtures live in `clients/proto/fixtures/behavior/statuses/`. Each case +contains a `status` object that parses as +`mxaccess_gateway.v1.MxStatusProxy`. + +Clients use these fixtures to verify typed projections and raw fallback +behavior. A language helper may expose native booleans, integers, strings, +arrays, and timestamps, but it must keep `rawDiagnostic`, raw data type fields, +and raw byte payloads accessible when conversion is incomplete. + +## Auth, Timeout, And Cancel Behavior + +Authentication fixtures live in `clients/proto/fixtures/behavior/auth/`. They +separate `UNAUTHENTICATED` from `PERMISSION_DENIED` so clients map missing or +invalid credentials differently from missing scopes. Expected output strings +contain only redacted credentials. + +Timeout and cancellation fixtures live in +`clients/proto/fixtures/behavior/timeout-cancel/`. They document that canceling +or timing out a client call stops the client from waiting, but it does not abort +an in-flight MXAccess COM call on the worker STA. Clients should follow up with +`GetSessionState` or `CloseSession` before reusing handles after an uncertain +command timeout. + +## Validation + +Run the fixture validation tests after changing the behavior fixture set: + +```bash +powershell -ExecutionPolicy Bypass -File scripts/validate-client-behavior-fixtures.ps1 +``` + +The script runs the focused C# contract tests that parse all protobuf JSON +fixtures and validate deterministic wrapper expectation files. + +## Related Documentation + +- [Client Proto Generation](./client-proto-generation.md) +- [Client Libraries Detailed Design](./client-libraries-design.md) +- [Protobuf Contracts](./Contracts.md) diff --git a/docs/client-libraries-design.md b/docs/client-libraries-design.md index b2cf188..f2e2054 100644 --- a/docs/client-libraries-design.md +++ b/docs/client-libraries-design.md @@ -29,6 +29,7 @@ Language-specific plans: Shared generation inputs: - `docs/client-proto-generation.md` +- `docs/ClientBehaviorFixtures.md` - `clients/proto/proto-inputs.json` Language style guides: @@ -310,6 +311,11 @@ CLI output should support JSON for automated tests. Unit tests must run without a live gateway. Use fake gRPC services, mock transports, or generated test servers depending on language. +Shared behavior fixtures live in `clients/proto/fixtures/behavior`. Every +client should include tests that load the fixture manifest and verify wrapper +behavior against the common command reply, event stream, value conversion, +status conversion, auth error, and timeout/cancel cases. + Required unit test areas: - options parsing, diff --git a/docs/client-proto-generation.md b/docs/client-proto-generation.md index 88f8a92..4686fcc 100644 --- a/docs/client-proto-generation.md +++ b/docs/client-proto-generation.md @@ -16,6 +16,7 @@ records: - the public and worker source files, - the descriptor set path, - golden fixture locations, +- behavior fixture locations, - generated-code output directories for each planned client. The source files listed by the manifest are: @@ -125,9 +126,30 @@ The fixtures use protobuf JSON field names and enum values. Contract tests parse them with the generated C# types so schema drift is caught before client generation work starts. +## Behavior Fixtures + +Cross-language behavior fixtures live in +`clients/proto/fixtures/behavior`. The manifest +`clients/proto/fixtures/behavior/manifest.json` lists command replies, ordered +event stream samples, value conversion cases, status conversion cases, auth +error expectations, and timeout/cancel expectations. + +The behavior fixtures let each generated client wrapper test the same +expectations without a live gateway. Protobuf message fixtures parse with the +generated types. Auth and timeout/cancel files describe wrapper behavior above +the generated transport layer, including credential redaction and the rule that +client cancellation does not abort an in-flight MXAccess COM call. + +Run the focused validation script after changing these fixtures: + +```powershell +scripts/validate-client-behavior-fixtures.ps1 +``` + ## Related Documentation - [Protobuf Contracts](./Contracts.md) - [Client Libraries Detailed Design](./client-libraries-design.md) +- [Client Behavior Fixtures](./ClientBehaviorFixtures.md) - [Client Libraries Implementation Plan](./implementation-plan-clients.md) - [Protobuf Style Guide](./style-guides/ProtobufStyleGuide.md) diff --git a/scripts/validate-client-behavior-fixtures.ps1 b/scripts/validate-client-behavior-fixtures.ps1 new file mode 100644 index 0000000..6f1ea3b --- /dev/null +++ b/scripts/validate-client-behavior-fixtures.ps1 @@ -0,0 +1,26 @@ +[CmdletBinding()] +param( + [switch]$NoBuild +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +$testProject = Join-Path $repoRoot "src/MxGateway.Tests/MxGateway.Tests.csproj" +$arguments = @( + "test", + $testProject, + "--filter", + "ClientBehaviorFixtureTests" +) + +if ($NoBuild) { + $arguments += "--no-build" +} + +& dotnet @arguments + +if ($LASTEXITCODE -ne 0) { + throw "Client behavior fixture validation failed with exit code $LASTEXITCODE." +} diff --git a/src/MxGateway.Tests/Contracts/ClientBehaviorFixtureTests.cs b/src/MxGateway.Tests/Contracts/ClientBehaviorFixtureTests.cs new file mode 100644 index 0000000..d48979e --- /dev/null +++ b/src/MxGateway.Tests/Contracts/ClientBehaviorFixtureTests.cs @@ -0,0 +1,379 @@ +using System.Text.Json; +using Google.Protobuf; +using MxGateway.Contracts; +using MxGateway.Contracts.Proto; + +namespace MxGateway.Tests.Contracts; + +public sealed class ClientBehaviorFixtureTests +{ + private static readonly JsonParser ProtobufJsonParser = new(JsonParser.Settings.Default); + + [Fact] + public void BehaviorManifest_DeclaresCurrentProtocolVersionsAndExistingFixtures() + { + using JsonDocument manifest = LoadBehaviorManifest(); + JsonElement root = manifest.RootElement; + + Assert.Equal(1, root.GetProperty("schemaVersion").GetInt32()); + Assert.Equal("mxaccess-gateway-client-behavior", root.GetProperty("fixtureSet").GetString()); + Assert.Equal(GatewayContractInfo.GatewayProtocolVersion, root.GetProperty("gatewayProtocolVersion").GetUInt32()); + Assert.Equal(GatewayContractInfo.WorkerProtocolVersion, root.GetProperty("workerProtocolVersion").GetUInt32()); + + HashSet fixtureIds = new(StringComparer.Ordinal); + foreach (JsonElement fixture in root.GetProperty("fixtures").EnumerateArray()) + { + string id = fixture.GetProperty("id").GetString()!; + string path = fixture.GetProperty("path").GetString()!; + string category = fixture.GetProperty("category").GetString()!; + string messageType = fixture.GetProperty("messageType").GetString()!; + + Assert.True(fixtureIds.Add(id), $"Duplicate behavior fixture id '{id}'."); + Assert.Contains(category, KnownCategories); + Assert.Contains(messageType, KnownMessageTypes); + Assert.True( + File.Exists(Path.Combine(GetBehaviorFixtureRoot().FullName, path)), + $"Expected behavior fixture '{path}' to exist."); + Assert.False(Path.IsPathRooted(path), $"Fixture path '{path}' must be relative."); + Assert.NotEmpty(fixture.GetProperty("expectation").GetString()!); + } + } + + [Fact] + public void ProtoInputManifest_ReferencesBehaviorFixtureRoot() + { + DirectoryInfo repositoryRoot = FindRepositoryRoot(); + string manifestPath = Path.Combine(repositoryRoot.FullName, "clients", "proto", "proto-inputs.json"); + + using JsonDocument manifest = JsonDocument.Parse(File.ReadAllText(manifestPath)); + string fixtureRoot = manifest.RootElement.GetProperty("behaviorFixtureRoot").GetString()!; + + Assert.Equal("clients/proto/fixtures/behavior", fixtureRoot); + Assert.True(Directory.Exists(Path.Combine(repositoryRoot.FullName, fixtureRoot))); + } + + [Fact] + public void CommandReplyFixtures_ParseWithCurrentContractAndPreserveMxAccessDetails() + { + IReadOnlyList fixtures = LoadManifestFixtures("command_replies"); + Assert.NotEmpty(fixtures); + + foreach (JsonElement fixture in fixtures) + { + MxCommandReply reply = ParseFixture( + fixture, + MxCommandReply.Parser); + + Assert.NotEqual(MxCommandKind.Unspecified, reply.Kind); + Assert.NotEqual(ProtocolStatusCode.Unspecified, reply.ProtocolStatus.Code); + Assert.True(reply.HasHresult, $"Fixture '{GetFixtureId(fixture)}' must carry an HRESULT."); + Assert.NotEmpty(reply.Statuses); + Assert.NotEqual(MxDataType.Unspecified, reply.ReturnValue.DataType); + Assert.True( + reply.ReturnValue.KindCase != MxValue.KindOneofCase.None || reply.ReturnValue.IsNull, + $"Fixture '{GetFixtureId(fixture)}' must carry a typed value, raw value, or explicit null."); + } + + MxCommandReply failedWrite = ParseFixture( + Assert.Single(fixtures, fixture => GetFixtureId(fixture) == "command-reply.write.mxaccess-failure"), + MxCommandReply.Parser); + + Assert.Equal(ProtocolStatusCode.MxaccessFailure, failedWrite.ProtocolStatus.Code); + Assert.Equal(-2147220992, failedWrite.Hresult); + Assert.True(failedWrite.Statuses.Count > 1); + Assert.All(failedWrite.Statuses, status => Assert.Equal(0, status.Success)); + } + + [Fact] + public void EventStreamFixtures_ParseWithMonotonicSequencesAndExpectedFamilies() + { + IReadOnlyList fixtures = LoadManifestFixtures("event_streams"); + Assert.NotEmpty(fixtures); + + foreach (JsonElement fixture in fixtures) + { + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(GetFixturePath(fixture))); + ulong previousSequence = 0; + List families = []; + + foreach (JsonElement eventElement in document.RootElement.GetProperty("events").EnumerateArray()) + { + MxEvent gatewayEvent = ProtobufJsonParser.Parse(eventElement.GetRawText()); + + Assert.True(gatewayEvent.WorkerSequence > previousSequence); + Assert.Equal(document.RootElement.GetProperty("sessionId").GetString(), gatewayEvent.SessionId); + Assert.NotEmpty(gatewayEvent.Statuses); + AssertEventBodyMatchesFamily(gatewayEvent); + + previousSequence = gatewayEvent.WorkerSequence; + families.Add(gatewayEvent.Family); + } + + Assert.Contains(MxEventFamily.OnDataChange, families); + Assert.Contains(MxEventFamily.OnWriteComplete, families); + Assert.Contains(MxEventFamily.OperationComplete, families); + Assert.Contains(MxEventFamily.OnBufferedDataChange, families); + } + } + + [Fact] + public void ValueConversionFixtures_ParseTypedValuesAndRawFallbacks() + { + JsonElement cases = LoadCaseSet("value_conversion", "cases"); + bool sawRawFallback = false; + bool sawRawArrayFallback = false; + bool sawTypedArray = false; + + foreach (JsonElement valueCase in cases.EnumerateArray()) + { + MxValue value = ProtobufJsonParser.Parse( + valueCase.GetProperty("value").GetRawText()); + string expectedKind = valueCase.GetProperty("expectedKind").GetString()!; + + Assert.NotEqual(MxDataType.Unspecified, value.DataType); + AssertJsonKindMatchesValueKind(expectedKind, value); + + sawRawFallback |= value.KindCase == MxValue.KindOneofCase.RawValue + && !string.IsNullOrWhiteSpace(value.RawDiagnostic) + && value.RawDataType != 0; + sawRawArrayFallback |= value.KindCase == MxValue.KindOneofCase.ArrayValue + && value.ArrayValue.ValuesCase == MxArray.ValuesOneofCase.RawValues + && !string.IsNullOrWhiteSpace(value.ArrayValue.RawDiagnostic) + && value.ArrayValue.RawElementDataType != 0; + sawTypedArray |= value.KindCase == MxValue.KindOneofCase.ArrayValue + && value.ArrayValue.ValuesCase != MxArray.ValuesOneofCase.RawValues; + } + + Assert.True(sawRawFallback, "Expected at least one raw scalar fallback case."); + Assert.True(sawRawArrayFallback, "Expected at least one raw array fallback case."); + Assert.True(sawTypedArray, "Expected at least one typed array case."); + } + + [Fact] + public void StatusConversionFixtures_ParseStatusArraysAndRawFields() + { + JsonElement cases = LoadCaseSet("status_conversion", "cases"); + bool sawRawUnknown = false; + + foreach (JsonElement statusCase in cases.EnumerateArray()) + { + MxStatusProxy status = ProtobufJsonParser.Parse( + statusCase.GetProperty("status").GetRawText()); + + Assert.NotEqual(MxStatusCategory.Unspecified, status.Category); + Assert.NotEqual(MxStatusSource.Unspecified, status.DetectedBy); + Assert.NotEmpty(status.DiagnosticText); + + sawRawUnknown |= status.Category == MxStatusCategory.Unknown + && status.RawCategory != 0 + && status.RawDetectedBy != 0; + } + + Assert.True(sawRawUnknown, "Expected a status case with unknown raw native fields."); + } + + [Fact] + public void AuthErrorFixtures_MapAuthenticationAuthorizationAndRedactCredentials() + { + JsonElement cases = LoadCaseSet("auth_errors", "cases"); + HashSet statusCodes = new(StringComparer.Ordinal); + + foreach (JsonElement authCase in cases.EnumerateArray()) + { + string grpcStatusCode = authCase.GetProperty("grpcStatusCode").GetString()!; + string category = authCase.GetProperty("clientErrorCategory").GetString()!; + string redactedOutput = authCase.GetProperty("expectedRedactedOutput").GetString()!; + string serialized = authCase.GetRawText(); + + Assert.Contains(grpcStatusCode, AuthGrpcStatusCodes); + Assert.Contains(category, AuthClientErrorCategories); + string authorization = authCase.GetProperty("inputMetadata").GetProperty("authorization").GetString()!; + if (!string.IsNullOrEmpty(authorization)) + { + Assert.Contains("", serialized); + } + + Assert.DoesNotContain("mxgw_", serialized, StringComparison.Ordinal); + Assert.DoesNotContain("secret", redactedOutput, StringComparison.OrdinalIgnoreCase); + + statusCodes.Add(grpcStatusCode); + } + + Assert.Contains("UNAUTHENTICATED", statusCodes); + Assert.Contains("PERMISSION_DENIED", statusCodes); + } + + [Fact] + public void TimeoutCancelFixtures_DocumentClientWaitAndWorkerCommandBehavior() + { + JsonElement cases = LoadCaseSet("timeout_cancel", "cases"); + HashSet statusCodes = new(StringComparer.Ordinal); + + foreach (JsonElement timeoutCase in cases.EnumerateArray()) + { + string grpcStatusCode = timeoutCase.GetProperty("grpcStatusCode").GetString()!; + + Assert.Contains(grpcStatusCode, TimeoutGrpcStatusCodes); + Assert.NotEmpty(timeoutCase.GetProperty("clientDeadline").GetString()!); + Assert.NotEmpty(timeoutCase.GetProperty("gatewayWaitBehavior").GetString()!); + Assert.NotEmpty(timeoutCase.GetProperty("workerCommandBehavior").GetString()!); + Assert.NotEmpty(timeoutCase.GetProperty("expectedClientAction").GetString()!); + + statusCodes.Add(grpcStatusCode); + } + + Assert.Contains("DEADLINE_EXCEEDED", statusCodes); + Assert.Contains("CANCELLED", statusCodes); + } + + private static readonly string[] KnownCategories = + [ + "command_replies", + "event_streams", + "value_conversion", + "status_conversion", + "auth_errors", + "timeout_cancel", + ]; + + private static readonly string[] KnownMessageTypes = + [ + "mxaccess_gateway.v1.MxCommandReply", + "mxaccess_gateway.v1.MxEvent", + "mxaccess_gateway.v1.MxValue", + "mxaccess_gateway.v1.MxStatusProxy", + "client_behavior.v1.AuthErrorCase", + "client_behavior.v1.TimeoutCancelCase", + ]; + + private static readonly string[] AuthGrpcStatusCodes = + [ + "UNAUTHENTICATED", + "PERMISSION_DENIED", + ]; + + private static readonly string[] AuthClientErrorCategories = + [ + "AuthenticationError", + "AuthorizationError", + ]; + + private static readonly string[] TimeoutGrpcStatusCodes = + [ + "DEADLINE_EXCEEDED", + "CANCELLED", + ]; + + private static T ParseFixture( + JsonElement fixture, + MessageParser parser) + where T : IMessage + { + return parser.ParseJson(File.ReadAllText(GetFixturePath(fixture))); + } + + private static JsonElement LoadCaseSet( + string category, + string propertyName) + { + JsonElement fixture = Assert.Single(LoadManifestFixtures(category)); + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(GetFixturePath(fixture))); + + return document.RootElement.GetProperty(propertyName).Clone(); + } + + private static IReadOnlyList LoadManifestFixtures(string category) + { + using JsonDocument manifest = LoadBehaviorManifest(); + + return manifest.RootElement + .GetProperty("fixtures") + .EnumerateArray() + .Where(fixture => fixture.GetProperty("category").GetString() == category) + .Select(fixture => fixture.Clone()) + .ToArray(); + } + + private static JsonDocument LoadBehaviorManifest() + { + return JsonDocument.Parse(File.ReadAllText(Path.Combine(GetBehaviorFixtureRoot().FullName, "manifest.json"))); + } + + private static string GetFixturePath(JsonElement fixture) + { + return Path.Combine(GetBehaviorFixtureRoot().FullName, fixture.GetProperty("path").GetString()!); + } + + private static string GetFixtureId(JsonElement fixture) + { + return fixture.GetProperty("id").GetString()!; + } + + private static DirectoryInfo GetBehaviorFixtureRoot() + { + DirectoryInfo repositoryRoot = FindRepositoryRoot(); + + return new DirectoryInfo(Path.Combine(repositoryRoot.FullName, "clients", "proto", "fixtures", "behavior")); + } + + private static DirectoryInfo FindRepositoryRoot() + { + DirectoryInfo? current = new(AppContext.BaseDirectory); + + while (current is not null) + { + if (File.Exists(Path.Combine(current.FullName, "AGENTS.md")) + && Directory.Exists(Path.Combine(current.FullName, "src")) + && Directory.Exists(Path.Combine(current.FullName, "clients"))) + { + return current; + } + + current = current.Parent; + } + + throw new DirectoryNotFoundException("Could not locate the repository root from the test output directory."); + } + + private static void AssertEventBodyMatchesFamily(MxEvent gatewayEvent) + { + switch (gatewayEvent.Family) + { + case MxEventFamily.OnDataChange: + Assert.Equal(MxEvent.BodyOneofCase.OnDataChange, gatewayEvent.BodyCase); + break; + case MxEventFamily.OnWriteComplete: + Assert.Equal(MxEvent.BodyOneofCase.OnWriteComplete, gatewayEvent.BodyCase); + break; + case MxEventFamily.OperationComplete: + Assert.Equal(MxEvent.BodyOneofCase.OperationComplete, gatewayEvent.BodyCase); + break; + case MxEventFamily.OnBufferedDataChange: + Assert.Equal(MxEvent.BodyOneofCase.OnBufferedDataChange, gatewayEvent.BodyCase); + break; + default: + throw new InvalidOperationException($"Unexpected event family '{gatewayEvent.Family}'."); + } + } + + private static void AssertJsonKindMatchesValueKind( + string expectedKind, + MxValue value) + { + MxValue.KindOneofCase expected = expectedKind switch + { + "boolValue" => MxValue.KindOneofCase.BoolValue, + "int32Value" => MxValue.KindOneofCase.Int32Value, + "int64Value" => MxValue.KindOneofCase.Int64Value, + "floatValue" => MxValue.KindOneofCase.FloatValue, + "doubleValue" => MxValue.KindOneofCase.DoubleValue, + "stringValue" => MxValue.KindOneofCase.StringValue, + "timestampValue" => MxValue.KindOneofCase.TimestampValue, + "arrayValue" => MxValue.KindOneofCase.ArrayValue, + "rawValue" => MxValue.KindOneofCase.RawValue, + _ => throw new InvalidOperationException($"Unexpected expected value kind '{expectedKind}'."), + }; + + Assert.Equal(expected, value.KindCase); + } +}