diff --git a/docs/adr-004-pluggable-protocol-codecs.md b/docs/adr-004-pluggable-protocol-codecs.md index 7d955c8f..0eafeb7e 100644 --- a/docs/adr-004-pluggable-protocol-codecs.md +++ b/docs/adr-004-pluggable-protocol-codecs.md @@ -367,6 +367,29 @@ Design choices: generic parameter. - A `codec()` accessor was added to `WireframeApp` for convenience. +### Codec test fixtures (resolved 2026-02-28) + +Roadmap item 9.7.2 added codec fixture functions to `wireframe_testing` for +generating valid, invalid, incomplete, and correlation-bearing Hotline-framed +wire bytes. These fixtures support test authors exercising error paths without +hand-crafting raw byte sequences. + +Design choices: + +- Fixtures produce raw `Vec` wire bytes rather than typed `HotlineFrame` + values. Invalid and malformed frames cannot be represented as valid typed + values, and wire bytes are what decoders and test drivers consume directly. A + `valid_hotline_frame` convenience function is also provided for tests needing + metadata inspection without a wire round-trip. +- Fixtures are Hotline-specific rather than generic over `FrameCodec`. + Generating invalid frames requires knowledge of the specific wire format + (header layout, field positions, size constraints). A generic approach would + need a `MalformedFrameGenerator` trait, which is over-engineering for a test + utility. If additional codecs need fixtures, they can follow the same pattern. +- Fixtures construct headers directly using big-endian `u32` writes, bypassing + the tokio-util encoder. This ensures fixtures are independent of encoder + implementation and can represent data the encoder would reject. + ## Architectural Rationale A dedicated `FrameCodec` abstraction aligns framing with the protocol boundary diff --git a/docs/execplans/9-7-2-codec-fixtures-in-wireframe-testing.md b/docs/execplans/9-7-2-codec-fixtures-in-wireframe-testing.md new file mode 100644 index 00000000..3914f7e8 --- /dev/null +++ b/docs/execplans/9-7-2-codec-fixtures-in-wireframe-testing.md @@ -0,0 +1,582 @@ +# 9.7.2 Add codec fixtures in wireframe\_testing + +This ExecPlan (execution plan) is a living document. The sections +`Constraints`, `Tolerances`, `Risks`, `Progress`, `Surprises & Discoveries`, +`Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work +proceeds. + +Status: COMPLETE + +## Purpose / big picture + +The `wireframe_testing` crate already provides codec-aware driver functions +(added in 9.7.1) that encode payloads, transport them over an in-memory duplex +stream, and decode the response. However, test authors who need to exercise +error paths — malformed frames, oversized payloads, truncated headers, or +frames carrying specific correlation metadata — must hand-craft raw byte +sequences each time. This is tedious, error-prone, and leads to duplicated +setup across test suites. + +After this change, `wireframe_testing` exports a set of builder helpers that +produce ready-to-use byte sequences and typed frames for common codec test +scenarios. A test author can write: + +```rust +use wireframe_testing::{ + valid_hotline_wire, oversized_hotline_wire, + truncated_hotline_header, correlated_hotline_wire, +}; + +// Valid frame bytes ready for decode_frames_with_codec +let wire = valid_hotline_wire(b"hello", 7); + +// Oversized payload that should be rejected by the decoder +let too_big = oversized_hotline_wire(4096); + +// Frame with only a partial header — decoder should return Ok(None) +let partial = truncated_hotline_header(); + +// Sequence of frames sharing a correlation (transaction) ID +let correlated = correlated_hotline_wire(42, &[b"a", b"b", b"c"]); +``` + +Observable success: `make test` passes, including new unit tests in +`tests/codec_fixtures.rs` and new rstest-bdd behavioural scenarios in +`tests/scenarios/codec_fixtures_scenarios.rs` that exercise the fixture +functions with `HotlineFrameCodec`. The existing codec test harness tests +remain green. + +## Constraints + +- All existing `wireframe_testing` public APIs must remain source-compatible. + The new functions are purely additive. +- No file may exceed 400 lines. +- Use `rstest` for new unit tests and `rstest-bdd` v0.5.0 for new behavioural + tests. +- Use existing in-tree codecs (`HotlineFrameCodec` from + `wireframe::codec::examples`) rather than defining new codec types. +- en-GB-oxendict spelling in all comments and documentation. +- Strict Clippy with `-D warnings` on all targets including tests. +- Every module must begin with a `//!` doc comment. +- Public functions must have `///` doc comments with examples. +- No `assert!`/`panic!` in functions returning `Result` (clippy + `panic_in_result_fn`). +- No array indexing in tests — use `.get(n)` / `.first()` instead. +- `#[expect]` attributes must include `reason = "..."`. +- Quality gates (`make check-fmt`, `make lint`, `make test`, + `make markdownlint`) must pass before completion. +- Tests for `wireframe_testing` functionality must live in the main crate's + `tests/` directory as integration tests, because `wireframe_testing` is a + dev-dependency of the main crate, not a workspace member. Internal + `#[cfg(test)]` modules within `wireframe_testing` never execute via + `cargo test`. + +## Tolerances (exception triggers) + +- Scope: if implementation requires more than 12 new/modified files or 1,000 + net changed lines, stop and re-scope. +- Dependencies: if any new external crate is required beyond what is already in + `wireframe_testing/Cargo.toml`, stop and escalate. +- Interface: if a public API signature on the main `wireframe` crate must + change beyond an additive accessor, stop and escalate. +- Iterations: if the same failing root cause persists after 3 fix attempts, + stop and document options in the Decision Log. + +## Risks + +- Risk: the Hotline decoder returns `Ok(None)` for truncated data (insufficient + bytes for a header or payload) rather than `Err(...)`. This means + `decode_frames_with_codec` will treat truncated frames as "no more frames" + rather than an error, unless `decode_eof` detects unconsumed bytes. Fixtures + producing truncated data must account for this: they will trigger the + `decode_eof` "bytes remaining on stream" error path in + `decode_frames_with_codec`, not a decoder error. Severity: medium. + Likelihood: certain. Mitigation: document this in the fixture API and ensure + BDD tests assert on "bytes remaining" errors specifically. + +- Risk: the `codec_fixtures.rs` module might grow beyond 400 lines if too many + fixture variants are added. Severity: low. Likelihood: low. Mitigation: start + with the minimum required fixtures (valid, oversized, truncated, correlated); + split into submodules only if the 400-line cap is approached. + +- Risk: Hotline encoder rejects oversized payloads at encode time, so + "oversized" fixtures must produce raw bytes directly rather than going + through the encoder. Severity: low. Likelihood: certain. Mitigation: + oversized fixture hand-crafts the 20-byte Hotline header + oversized payload + bytes. + +## Progress + +- [x] (2026-02-28) Drafted ExecPlan. +- [x] (2026-02-28) Stage A: implement codec fixture module in + `wireframe_testing`. +- [x] (2026-02-28) Stage B: add rstest unit tests for the fixtures (8 tests, + all green). +- [x] (2026-02-28) Stage C: add rstest-bdd behavioural tests (4 scenarios, all + green). +- [x] (2026-02-28) Stage D: documentation, design doc, and roadmap updates. +- [x] (2026-02-28) Stage E: full validation and evidence capture. + +## Surprises & discoveries + +- The Hotline decoder does not override `decode_eof`, so the default + tokio-util implementation is used. When `decode()` returns `Ok(None)` but the + buffer is non-empty, `decode_eof` returns `Err("bytes remaining on stream")` + rather than `Ok(None)`. This means truncated fixtures trigger an error from + `decode_eof` (wrapped as + `"codec decode_eof failed: bytes remaining on stream"`) rather than from the + trailing-bytes check in `decode_frames_with_codec`. The docstrings and BDD + tests were updated to assert on `"bytes remaining"` instead of `"trailing"`. + +## Decision log + +- Decision: produce raw `Vec` wire bytes as the primary fixture output + format rather than typed `Frame` values. Rationale: invalid and malformed + frames cannot exist as valid typed values (e.g., `HotlineFrame` always has a + well-formed payload). Wire bytes are what decoders and test drivers consume + directly. For valid-frame fixtures, provide both raw bytes and a convenience + function returning the typed frame for metadata inspection. Date/Author: + 2026-02-28 / Plan phase. + +- Decision: provide Hotline-specific fixture functions rather than + `FrameCodec`-generic fixtures. Rationale: generating invalid frames requires + knowledge of the specific wire format (header layout, field positions, size + constraints). A generic approach would need a `MalformedFrameGenerator` trait + that every codec implements, which is over-engineering for a test utility. + `HotlineFrameCodec` is the project's canonical example codec used across all + codec tests. If additional codecs need fixtures in the future, they can be + added as separate fixture functions following the same pattern. Date/Author: + 2026-02-28 / Plan phase. + +- Decision: place fixtures in a new `codec_fixtures` submodule under + `wireframe_testing/src/helpers/` rather than a top-level module. Rationale: + this follows the existing pattern where all codec-related helpers live under + `helpers/` (codec.rs, codec\_ext.rs, codec\_drive.rs). It keeps the module + hierarchy consistent and the fixture functions close to the encode/decode + helpers they build upon. Date/Author: 2026-02-28 / Plan phase. + +## Outcomes & retrospective + +Completed 2026-02-28. All quality gates pass: + +- `make fmt` — clean (no formatting changes) +- `make check-fmt` — clean +- `make markdownlint` — clean (0 errors) +- `make lint` — clean (cargo doc + clippy with `-D warnings`) +- `make test` — all green (0 failures) + +New test coverage: + +- 8 integration tests in `tests/codec_fixtures.rs` (valid wire decode, valid + frame metadata, oversized rejection, mismatched total\_size rejection, + truncated header error, truncated payload error, correlated transaction IDs, + sequential transaction IDs). +- 4 BDD scenarios in `tests/scenarios/codec_fixtures_scenarios.rs` (valid + decode, oversized rejection, truncated error, correlated IDs). + +Files created: 6 (`codec_fixtures.rs` in wireframe\_testing helpers, +`codec_fixtures.rs` integration tests, `.feature` file, BDD fixture/steps/ +scenarios). Files modified: 8 (helpers.rs, lib.rs, three BDD mod.rs files, +users-guide.md, roadmap.md, adr-004.md). + +Key discovery: truncated frame fixtures trigger the `decode_eof` "bytes +remaining on stream" error path rather than the trailing-bytes check in +`decode_frames_with_codec`. This is because the default tokio-util `decode_eof` +implementation detects leftover bytes before the explicit trailing check runs. +Future fixture work should account for this when designing error assertions. + +## Context and orientation + +### Repository layout (relevant subset) + +```plaintext +src/ + codec.rs # FrameCodec trait (line 63-103) + codec/examples.rs # HotlineFrameCodec (line 21-131), MysqlFrameCodec + +wireframe_testing/ + Cargo.toml # dev-dependency on wireframe (path = "..") + src/lib.rs # public re-exports (68 lines) + src/helpers.rs # TestSerializer trait, module root, constants (71 lines) + src/helpers/codec.rs # new_test_codec, decode_frames, encode_frame (77 lines) + src/helpers/codec_ext.rs # encode_payloads_with_codec, decode_frames_with_codec, + # extract_payloads (144 lines) + src/helpers/codec_drive.rs # drive_with_codec_payloads/frames (279 lines) + src/helpers/drive.rs # drive_internal, drive_with_frame[s] (297 lines) + src/helpers/payloads.rs # drive_with_payloads, drive_with_bincode (152 lines) + src/helpers/runtime.rs # run_app, run_with_duplex_server (89 lines) + src/integration_helpers.rs # TestApp, TestError, TestResult, factory (294 lines) + +tests/ + codec_test_harness.rs # Integration tests for codec drivers (162 lines) + fixtures/mod.rs # BDD world fixtures (34 modules) + fixtures/codec_test_harness.rs # CodecTestHarnessWorld (132 lines) + steps/mod.rs # BDD step definitions (35 modules) + steps/codec_test_harness_steps.rs # Codec harness steps (41 lines) + scenarios/mod.rs # BDD scenario registrations (37 modules) + scenarios/codec_test_harness_scenarios.rs # 2 scenario tests (26 lines) + features/codec_test_harness.feature # 2 Gherkin scenarios (15 lines) + +docs/ + roadmap.md # Item 9.7.2 at line 435-436 + users-guide.md # Existing wireframe_testing section at line 224 + generic-message-fragmentation-and-re-assembly-design.md + hardening-wireframe-a-guide-to-production-resilience.md +``` + +### Key types + +`FrameCodec` (defined in `src/codec.rs:63-103`) is a trait requiring +`Send + Sync + Clone + 'static` with associated types `Frame`, `Decoder`, +`Encoder` and methods `decoder()`, `encoder()`, `frame_payload()`, +`wrap_payload()`, `correlation_id()`, and `max_frame_length()`. + +`HotlineFrameCodec` (defined in `src/codec/examples.rs:21-131`) uses a 20-byte +header: `data_size: u32`, `total_size: u32`, `transaction_id: u32`, +`reserved: [u8; 8]`, followed by payload bytes. The decoder returns `Ok(None)` +when fewer than 20 bytes are available or when the full frame has not arrived. +It returns `Err(InvalidData, "payload too large")` when +`data_size > max_frame_length` and `Err(InvalidData, "invalid total size")` +when `total_size != data_size + 20`. + +`decode_frames_with_codec` (in `wireframe_testing/src/helpers/codec_ext.rs:80`) +calls `decoder.decode()` in a loop, then `decoder.decode_eof()`, then checks +for trailing bytes. Trailing bytes produce +`Err(InvalidData, "trailing N byte(s) after decoding")`. + +Constants: `MIN_FRAME_LENGTH = 64`, `MAX_FRAME_LENGTH = 16 MiB`, +`LENGTH_HEADER_SIZE = 4`, `TEST_MAX_FRAME = 4096`. + +### BDD test pattern + +The project uses rstest-bdd v0.5.0 with a 4-file pattern per test domain: + +1. `tests/features/.feature` — Gherkin scenarios. +2. `tests/fixtures/.rs` — world struct with `#[fixture]` function. +3. `tests/steps/_steps.rs` — `#[given]`/`#[when]`/`#[then]` functions. +4. `tests/scenarios/_scenarios.rs` — `#[scenario]` functions. + +Steps are synchronous; async calls use +`tokio::runtime::Runtime::new()?.block_on(...)`. The fixture parameter name in +scenario functions must match the fixture function name exactly. All step +functions return `TestResult` (`Result<(), Box>`). + +Module wiring: `tests/scenarios/mod.rs` loads `tests/steps/mod.rs` via +`#[path = "../steps/mod.rs"] pub(crate) mod steps;`, then declares each +scenario submodule. + +## Plan of work + +### Stage A: implement codec fixture module in wireframe\_testing + +Create `wireframe_testing/src/helpers/codec_fixtures.rs` containing functions +that produce raw wire bytes for Hotline-framed test scenarios. The module +provides four categories of fixtures: + +**1. Valid frame fixtures** — properly encoded Hotline frames. + +`valid_hotline_wire(payload, transaction_id) -> Vec` where +`transaction_id: impl Into` — builds a single valid Hotline +frame as raw wire bytes by writing the 20-byte header (data\_size, total\_size, +transaction\_id, 8 reserved zero bytes) followed by the payload. This bypasses +the tokio-util encoder so fixtures are independent of the encoder +implementation. + +`valid_hotline_frame(payload, transaction_id) -> HotlineFrame` where +`transaction_id: impl Into` — returns a typed `HotlineFrame` for +tests needing metadata inspection. Delegates to +`HotlineFrameCodec::wrap_payload` but overrides the `transaction_id`. + +**2. Invalid frame fixtures** — wire bytes that should cause decode errors. + +`oversized_hotline_wire(max_frame_length: impl Into) -> Vec` +— crafts a Hotline header where `data_size` exceeds `max_frame_length` by 1 +byte, followed by that many payload bytes. The decoder should reject this with +`InvalidData("payload too large")`. + +`mismatched_total_size_wire(payload: &[u8]) -> Vec` — crafts a Hotline +header where `total_size` does not equal `data_size + 20`. The decoder should +reject this with `InvalidData("invalid total size")`. + +**3. Incomplete frame fixtures** — wire bytes that are valid prefixes but +insufficient for a complete frame. + +`truncated_hotline_header() -> Vec` — returns fewer than 20 bytes (e.g., 10 +bytes of zeroes). The Hotline decoder returns `Ok(None)` (insufficient bytes); +when passed to `decode_frames_with_codec` the `decode_eof` call detects +unconsumed bytes and produces an `InvalidData` error containing "bytes +remaining". + +`truncated_hotline_payload(payload_len: impl Into) -> Vec` — +writes a valid 20-byte header claiming `data_size = payload_len` but provides +only half the payload bytes. The Hotline decoder returns `Ok(None)` because the +buffer is too short; `decode_frames_with_codec` surfaces this via `decode_eof` +with an error containing "bytes remaining". + +**4. Correlation metadata fixtures** — frames with specific transaction IDs for +correlation testing. + +`correlated_hotline_wire(transaction_id, payloads) -> Vec` where +`transaction_id: impl Into` — encodes multiple Hotline frames +sharing the same `transaction_id`, suitable for verifying correlation ID +propagation across frame sequences. + +`sequential_hotline_wire(base_transaction_id, payloads) -> Vec` where +`base_transaction_id: impl Into` — encodes multiple frames with +incrementing transaction IDs (`base`, `base + 1`, …), suitable for verifying +frame ordering. + +Register the module in `wireframe_testing/src/helpers.rs` as +`mod codec_fixtures;` and add `pub use codec_fixtures::*;` exports. Add the +public re-exports to `wireframe_testing/src/lib.rs`. + +Stage A acceptance: `make check-fmt && make lint` pass. The new functions +compile and are accessible from the crate root via +`wireframe_testing::valid_hotline_wire` etc. + +### Stage B: add rstest unit tests for the fixtures + +Create `tests/codec_fixtures.rs` as an integration test file exercising every +fixture function. Tests live outside `wireframe_testing` because the crate is +not a workspace member. + +Tests to implement: + +1. `valid_hotline_wire_decodes_successfully` — encode a payload with + `valid_hotline_wire`, decode with `decode_frames_with_codec`, verify the + payload and transaction ID match. + +2. `valid_hotline_frame_has_correct_metadata` — call `valid_hotline_frame`, + verify `transaction_id` and payload bytes. + +3. `oversized_hotline_wire_rejected_by_decoder` — encode with + `oversized_hotline_wire(4096)`, attempt `decode_frames_with_codec` with a + codec capped at 4096, verify `Err` with `InvalidData`. + +4. `mismatched_total_size_rejected_by_decoder` — call + `mismatched_total_size_wire`, attempt decode, verify `Err` with + `InvalidData`. + +5. `truncated_header_produces_decode_error` — call + `truncated_hotline_header`, attempt decode, verify `Err` mentioning "bytes + remaining". + +6. `truncated_payload_produces_decode_error` — call + `truncated_hotline_payload(100)`, attempt decode, verify `Err` mentioning + "bytes remaining". + +7. `correlated_frames_share_transaction_id` — call `correlated_hotline_wire` + with `transaction_id = 42` and 3 payloads, decode, verify all 3 frames have + `transaction_id = 42`. + +8. `sequential_frames_have_incrementing_ids` — call `sequential_hotline_wire` + with `base = 10` and 3 payloads, decode, verify transaction IDs are 10, 11, + 12. + +Stage B acceptance: `make test` passes with all 8 new tests green. + +### Stage C: add rstest-bdd behavioural tests + +Create four files for the `codec_fixtures` BDD domain: + +**`tests/features/codec_fixtures.feature`**: + +```gherkin +Feature: Codec test fixtures + The wireframe_testing crate provides codec fixture functions for + generating valid and invalid Hotline-framed wire bytes for testing. + + Scenario: Valid fixture decodes to expected payload + Given a Hotline codec allowing frames up to 4096 bytes + When a valid fixture frame is decoded + Then the decoded payload matches the fixture input + + Scenario: Oversized fixture is rejected by decoder + Given a Hotline codec allowing frames up to 4096 bytes + When an oversized fixture frame is decoded + Then the decoder reports an invalid data error + + Scenario: Truncated fixture produces a bytes remaining error + Given a Hotline codec allowing frames up to 4096 bytes + When a truncated fixture frame is decoded + Then the decoder reports bytes remaining + + Scenario: Correlated fixtures share the same transaction identifier + Given a Hotline codec allowing frames up to 4096 bytes + When correlated fixture frames are decoded + Then all frames have the expected transaction identifier +``` + +**`tests/fixtures/codec_fixtures.rs`**: A `CodecFixturesWorld` struct holding +the codec, wire bytes, decoded frames, and any decode error. Methods: +`configure_codec(max_frame_length)`, `decode_valid_fixture()`, +`decode_oversized_fixture()`, `decode_truncated_fixture()`, +`decode_correlated_fixture()`, `verify_payload_matches()`, +`verify_invalid_data_error()`, `verify_bytes_remaining_error()`, +`verify_correlated_transaction_ids()`. + +**`tests/steps/codec_fixtures_steps.rs`**: Step functions matching the Gherkin +steps, delegating to world methods. + +**`tests/scenarios/codec_fixtures_scenarios.rs`**: Four `#[scenario]` functions. + +Wire into `tests/fixtures/mod.rs`, `tests/steps/mod.rs`, and +`tests/scenarios/mod.rs`. + +Stage C acceptance: `make test` passes with all 4 BDD scenarios green. + +### Stage D: documentation, design doc, and roadmap updates + +1. Update `docs/users-guide.md` — add a subsection under the existing "Testing + custom codecs with `wireframe_testing`" section (around line 224) + documenting the new codec fixture functions with a brief usage example + showing how to use `valid_hotline_wire`, `oversized_hotline_wire`, and + `correlated_hotline_wire`. + +2. Update the relevant design document + (`docs/hardening-wireframe-a-guide-to-production-resilience.md`) with a + short note recording the design decision to provide Hotline-specific + raw-byte fixtures rather than generic `FrameCodec`-parameterized generators, + and the rationale (invalid frames require format-specific knowledge). + +3. Mark roadmap item `9.7.2` as done in `docs/roadmap.md`: change + `- [ ] 9.7.2.` to `- [x] 9.7.2.` + +Stage D acceptance: `make markdownlint` passes. Documentation is internally +consistent. + +### Stage E: full validation and evidence capture + +Run all quality gates with logging: + + +```shell +set -o pipefail; make fmt 2>&1 | tee /tmp/9-7-2-fmt.log +set -o pipefail; make check-fmt 2>&1 | tee /tmp/9-7-2-check-fmt.log +set -o pipefail; make markdownlint MDLINT=/root/.bun/bin/markdownlint-cli2 2>&1 | tee /tmp/9-7-2-markdownlint.log +set -o pipefail; make lint 2>&1 | tee /tmp/9-7-2-lint.log +set -o pipefail; make test 2>&1 | tee /tmp/9-7-2-test.log +``` + + +Update the `Progress` and `Outcomes & Retrospective` sections with final +evidence and timestamps. + +Stage E acceptance: all commands exit 0. + +## Validation and acceptance + +Quality criteria (what "done" means): + +- Tests: `make test` passes, including 8 new unit tests in + `tests/codec_fixtures.rs` and 4 new BDD scenarios in + `tests/scenarios/codec_fixtures_scenarios.rs`. +- Lint: `make lint` passes (Clippy with `-D warnings` on all targets). +- Format: `make check-fmt` passes. +- Markdown: `make markdownlint` passes. +- Documentation: `docs/users-guide.md` documents the new fixture API. + `docs/roadmap.md` item 9.7.2 is marked done. + +Quality method: + +- Run the shell commands in Stage E and verify all exit 0. +- Verify the new BDD scenarios pass: `cargo test codec_fixtures`. +- Verify the new unit tests pass: `cargo test codec_fixtures`. + +## Idempotence and recovery + +All stages produce additive changes. If a stage fails partway through, the +incomplete changes can be reverted with `git checkout -- .` and the stage +retried from the beginning. No destructive operations are involved. + +## Interfaces and dependencies + +### New public functions (wireframe\_testing crate) + +In `wireframe_testing/src/helpers/codec_fixtures.rs`: + +```rust +/// Build a single valid Hotline frame as raw wire bytes. +/// +/// Writes the 20-byte Hotline header (data_size, total_size, +/// transaction_id, 8 reserved zero bytes) followed by the payload. +pub fn valid_hotline_wire(payload: &[u8], transaction_id: impl Into) -> Vec; + +/// Return a typed `HotlineFrame` with the given payload and transaction ID. +pub fn valid_hotline_frame(payload: &[u8], transaction_id: impl Into) -> HotlineFrame; + +/// Build a Hotline frame whose data_size exceeds `max_frame_length` by one +/// byte. The decoder should reject this with `InvalidData`. +pub fn oversized_hotline_wire(max_frame_length: impl Into) -> Vec; + +/// Build a Hotline frame with a mismatched total_size field. +/// The decoder should reject this with `InvalidData`. +pub fn mismatched_total_size_wire(payload: &[u8]) -> Vec; + +/// Return fewer than 20 bytes — a truncated Hotline header. +pub fn truncated_hotline_header() -> Vec; + +/// Return a valid Hotline header claiming `payload_len` bytes of payload, +/// but provide only half the payload bytes. +pub fn truncated_hotline_payload(payload_len: impl Into) -> Vec; + +/// Encode multiple Hotline frames sharing the same `transaction_id`. +pub fn correlated_hotline_wire( + transaction_id: impl Into, + payloads: &[&[u8]], +) -> Vec; + +/// Encode multiple Hotline frames with incrementing transaction IDs. +pub fn sequential_hotline_wire( + base_transaction_id: impl Into, + payloads: &[&[u8]], +) -> Vec; +``` + +### Files to create + +| File | Purpose | Est. lines | +| ------------------------------------------------- | ------------------------------ | ---------- | +| `wireframe_testing/src/helpers/codec_fixtures.rs` | Codec fixture functions | ~180 | +| `tests/codec_fixtures.rs` | Integration tests for fixtures | ~180 | +| `tests/features/codec_fixtures.feature` | BDD feature file | ~25 | +| `tests/fixtures/codec_fixtures.rs` | BDD world fixture | ~130 | +| `tests/steps/codec_fixtures_steps.rs` | BDD step definitions | ~60 | +| `tests/scenarios/codec_fixtures_scenarios.rs` | BDD scenario registrations | ~40 | + +### Files to modify + +| File | Change | +| -------------------------------------------------------------- | ---------------------------------------- | +| `wireframe_testing/src/helpers.rs` | Add `mod codec_fixtures;` and re-exports | +| `wireframe_testing/src/lib.rs` | Add re-exports for new public functions | +| `tests/fixtures/mod.rs` | Add `pub mod codec_fixtures;` | +| `tests/steps/mod.rs` | Add `mod codec_fixtures_steps;` | +| `tests/scenarios/mod.rs` | Add `mod codec_fixtures_scenarios;` | +| `docs/users-guide.md` | Document new fixture API | +| `docs/roadmap.md` | Mark 9.7.2 done | +| `docs/hardening-wireframe-a-guide-to-production-resilience.md` | Record design decision | + +### Artifacts and notes + +Reference pattern for raw Hotline header construction (from +`src/codec/examples.rs:84-103`, the encoder): + +```rust +const HEADER_LEN: usize = 20; +// data_size: u32 (payload length) +// total_size: u32 (data_size + 20) +// transaction_id: u32 +// reserved: 8 zero bytes +// payload bytes +``` + +Reference pattern for the BDD world: `tests/fixtures/codec_test_harness.rs` +(132 lines) — `CodecTestHarnessWorld` with manual `Debug` impl (because +`WireframeApp` does not implement `Debug`). The new `CodecFixturesWorld` does +not hold a `WireframeApp`, so `#[derive(Debug)]` can be used directly. + +Reference for trailing-bytes behaviour: `decode_frames_with_codec` in +`wireframe_testing/src/helpers/codec_ext.rs:106-113` — if `buf` is non-empty +after all decode loops, returns +`Err(InvalidData, "trailing N byte(s) after decoding")`. diff --git a/docs/roadmap.md b/docs/roadmap.md index 444fd3de..de998abd 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -432,7 +432,7 @@ integration boundaries. - [x] 9.7.1. Extend `wireframe_testing` with codec-aware drivers that can run `WireframeApp` instances configured with custom `FrameCodec` values. -- [ ] 9.7.2. Add codec fixtures in `wireframe_testing` for generating valid and +- [x] 9.7.2. Add codec fixtures in `wireframe_testing` for generating valid and invalid frames, including oversized payloads and correlation metadata. - [ ] 9.7.3. Introduce a test observability harness in `wireframe_testing` that captures logs and metrics per test run for asserting codec failures and diff --git a/docs/users-guide.md b/docs/users-guide.md index daa3d917..0c9c5c58 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -261,6 +261,50 @@ Supporting helpers for composing custom test patterns: - `decode_frames_with_codec` — decode wire bytes to frames. - `extract_payloads` — extract payload bytes from decoded frames. +#### Codec test fixtures + +The `wireframe_testing` crate provides fixture functions for generating +Hotline-framed wire bytes covering common test scenarios — valid frames, +invalid frames, incomplete (truncated) frames, and frames with correlation +metadata. These fixtures construct raw bytes directly, so they can represent +malformed data that the encoder would reject: + +```rust,no_run +use wireframe::codec::examples::HotlineFrameCodec; +use wireframe_testing::{ + valid_hotline_wire, oversized_hotline_wire, + truncated_hotline_header, correlated_hotline_wire, + decode_frames_with_codec, +}; + +let codec = HotlineFrameCodec::new(4096); + +// Valid frame — decodes cleanly. +let wire = valid_hotline_wire(b"hello", 7); +let frames = decode_frames_with_codec(&codec, wire).unwrap(); + +// Oversized frame — rejected with "payload too large". +let wire = oversized_hotline_wire(4096); +assert!(decode_frames_with_codec(&codec, wire).is_err()); + +// Truncated header — rejected with "bytes remaining on stream". +let wire = truncated_hotline_header(); +assert!(decode_frames_with_codec(&codec, wire).is_err()); + +// Correlated frames — all share the same transaction ID. +let wire = correlated_hotline_wire(42, &[b"a", b"b"]); +let frames = decode_frames_with_codec(&codec, wire).unwrap(); +``` + +Available fixture functions: + +- `valid_hotline_wire` / `valid_hotline_frame` — well-formed frames. +- `oversized_hotline_wire` — payload exceeds `max_frame_length`. +- `mismatched_total_size_wire` — header with incorrect `total_size`. +- `truncated_hotline_header` / `truncated_hotline_payload` — incomplete data. +- `correlated_hotline_wire` — frames sharing a transaction ID. +- `sequential_hotline_wire` — frames with incrementing transaction IDs. + #### Zero-copy payload extraction For performance-critical codecs, use `Bytes` instead of `Vec` for payload diff --git a/tests/codec_fixtures.rs b/tests/codec_fixtures.rs new file mode 100644 index 00000000..d0d52083 --- /dev/null +++ b/tests/codec_fixtures.rs @@ -0,0 +1,153 @@ +//! Integration tests for the codec fixture functions in `wireframe_testing`. +//! +//! These tests verify that each fixture category produces wire bytes with the +//! expected decoding behaviour when used with `HotlineFrameCodec`. +#![cfg(not(loom))] + +use std::io; + +use wireframe::codec::examples::HotlineFrameCodec; +use wireframe_testing::{ + correlated_hotline_wire, + decode_frames_with_codec, + mismatched_total_size_wire, + oversized_hotline_wire, + sequential_hotline_wire, + truncated_hotline_header, + truncated_hotline_payload, + valid_hotline_frame, + valid_hotline_wire, +}; + +fn hotline_codec() -> HotlineFrameCodec { HotlineFrameCodec::new(4096) } + +/// Decode `wire` with a fresh `HotlineFrameCodec` and verify that decoding +/// fails with an error message containing `expected_error_substring`. +fn assert_decode_fails_with(wire: Vec, expected_error_substring: &str) -> io::Result<()> { + let codec = hotline_codec(); + let result = decode_frames_with_codec(&codec, wire); + + let err = result + .err() + .ok_or_else(|| io::Error::other("expected decode to fail but it succeeded"))?; + if !err.to_string().contains(expected_error_substring) { + return Err(io::Error::other(format!( + "expected error containing '{expected_error_substring}', got: {err}" + ))); + } + Ok(()) +} + +/// Assert that `frames` contains exactly `expected` elements. +fn assert_frame_count( + frames: &[wireframe::codec::examples::HotlineFrame], + expected: usize, +) -> io::Result<()> { + if frames.len() != expected { + return Err(io::Error::other(format!( + "expected {expected} frame(s), got {}", + frames.len() + ))); + } + Ok(()) +} + +// ── Valid frame fixtures ──────────────────────────────────────────────── + +#[test] +fn valid_hotline_wire_decodes_successfully() -> io::Result<()> { + let wire = valid_hotline_wire(b"hello", 7); + let codec = hotline_codec(); + let frames = decode_frames_with_codec(&codec, wire)?; + + assert_frame_count(&frames, 1)?; + + let frame = frames + .first() + .ok_or_else(|| io::Error::other("expected one decoded frame"))?; + + if frame.transaction_id != 7 { + return Err(io::Error::other(format!( + "expected transaction_id 7, got {}", + frame.transaction_id + ))); + } + if frame.payload.as_ref() != b"hello" { + return Err(io::Error::other("payload mismatch")); + } + Ok(()) +} + +#[test] +fn valid_hotline_frame_has_correct_metadata() { + let frame = valid_hotline_frame(b"data", 42); + assert_eq!(frame.transaction_id, 42); + assert_eq!(frame.payload.as_ref(), b"data"); +} + +// ── Invalid frame fixtures ────────────────────────────────────────────── + +#[test] +fn oversized_hotline_wire_rejected_by_decoder() -> io::Result<()> { + let wire = oversized_hotline_wire(4096); + assert_decode_fails_with(wire, "payload too large") +} + +#[test] +fn mismatched_total_size_rejected_by_decoder() -> io::Result<()> { + let wire = mismatched_total_size_wire(b"test"); + assert_decode_fails_with(wire, "invalid total size") +} + +// ── Incomplete frame fixtures ─────────────────────────────────────────── + +#[test] +fn truncated_header_produces_decode_error() -> io::Result<()> { + let wire = truncated_hotline_header(); + assert_decode_fails_with(wire, "bytes remaining") +} + +#[test] +fn truncated_payload_produces_decode_error() -> io::Result<()> { + let wire = truncated_hotline_payload(100); + assert_decode_fails_with(wire, "bytes remaining") +} + +/// Verify each frame carries the expected transaction ID. +fn assert_transaction_ids( + frames: &[wireframe::codec::examples::HotlineFrame], + expected_ids: &[u32], +) -> io::Result<()> { + assert_frame_count(frames, expected_ids.len())?; + for (i, (frame, expected_id)) in frames.iter().zip(expected_ids.iter()).enumerate() { + if frame.transaction_id != *expected_id { + return Err(io::Error::other(format!( + "frame {i}: expected transaction_id {expected_id}, got {}", + frame.transaction_id + ))); + } + } + Ok(()) +} + +// ── Correlation metadata fixtures ─────────────────────────────────────── + +#[test] +fn correlated_frames_share_transaction_id() -> io::Result<()> { + let wire = correlated_hotline_wire(42, &[b"a", b"b", b"c"]); + let codec = hotline_codec(); + let frames = decode_frames_with_codec(&codec, wire)?; + + assert_frame_count(&frames, 3)?; + assert_transaction_ids(&frames, &[42, 42, 42]) +} + +#[test] +fn sequential_frames_have_incrementing_ids() -> io::Result<()> { + let wire = sequential_hotline_wire(10, &[b"x", b"y", b"z"]); + let codec = hotline_codec(); + let frames = decode_frames_with_codec(&codec, wire)?; + + assert_frame_count(&frames, 3)?; + assert_transaction_ids(&frames, &[10, 11, 12]) +} diff --git a/tests/features/codec_fixtures.feature b/tests/features/codec_fixtures.feature new file mode 100644 index 00000000..00cbc4c3 --- /dev/null +++ b/tests/features/codec_fixtures.feature @@ -0,0 +1,23 @@ +Feature: Codec test fixtures + The wireframe_testing crate provides codec fixture functions for + generating valid and invalid Hotline-framed wire bytes for testing. + + Scenario: Valid fixture decodes to expected payload + Given a Hotline codec allowing fixtures up to 4096 bytes + When a valid fixture frame is decoded + Then the decoded payload matches the fixture input + + Scenario: Oversized fixture is rejected by decoder + Given a Hotline codec allowing fixtures up to 4096 bytes + When an oversized fixture frame is decoded + Then the decoder reports an invalid data error + + Scenario: Truncated fixture produces a decode error + Given a Hotline codec allowing fixtures up to 4096 bytes + When a truncated fixture frame is decoded + Then the decoder reports bytes remaining on stream + + Scenario: Correlated fixtures share the same transaction identifier + Given a Hotline codec allowing fixtures up to 4096 bytes + When correlated fixture frames are decoded + Then all frames have the expected transaction identifier diff --git a/tests/fixtures/codec_fixtures.rs b/tests/fixtures/codec_fixtures.rs new file mode 100644 index 00000000..9e07c203 --- /dev/null +++ b/tests/fixtures/codec_fixtures.rs @@ -0,0 +1,173 @@ +//! Test world for codec fixture behavioural scenarios. +//! +//! Exercises the `wireframe_testing` codec fixture functions with +//! `HotlineFrameCodec` to verify that each fixture category produces +//! wire bytes with the expected decoding behaviour. + +use std::io; + +use rstest::fixture; +use wireframe::codec::{ + FrameCodec, + examples::{HotlineFrame, HotlineFrameCodec}, +}; +/// Re-export `TestResult` from `wireframe_testing` for use in steps. +pub use wireframe_testing::TestResult; + +/// BDD world holding the codec, decoded frames, and any decode error. +#[derive(Debug, Default)] +pub struct CodecFixturesWorld { + codec: Option, + decoded_frames: Vec, + decode_error: Option, +} + +/// Fixture for codec fixture scenarios used by rstest-bdd steps. +/// +/// Note: `rustfmt` collapses simple fixtures into one line, which triggers +/// `unused_braces`, so keep `rustfmt::skip`. +#[rustfmt::skip] +#[fixture] +pub fn codec_fixtures_world() -> CodecFixturesWorld { + CodecFixturesWorld::default() +} + +impl CodecFixturesWorld { + /// Configure the Hotline codec with the given maximum frame length. + /// + /// # Errors + /// Returns an error if the codec is already configured. + pub fn configure_codec(&mut self, max_frame_length: usize) -> TestResult { + if self.codec.is_some() { + return Err("codec already configured".into()); + } + self.codec = Some(HotlineFrameCodec::new(max_frame_length)); + Ok(()) + } + + /// Decode a valid fixture frame and store the results. + /// + /// # Errors + /// Returns an error if the codec is not configured or decoding fails + /// unexpectedly. + pub fn decode_valid_fixture(&mut self) -> TestResult { + let wire = wireframe_testing::valid_hotline_wire(b"fixture-payload", 7); + self.decode_fixture(wire) + } + + /// Decode an oversized fixture frame and store the error. + /// + /// # Errors + /// Returns an error if the codec is not configured. + pub fn decode_oversized_fixture(&mut self) -> TestResult { + let codec = self.codec.as_ref().ok_or("codec not configured")?; + let wire = wireframe_testing::oversized_hotline_wire(codec.max_frame_length()); + self.decode_fixture(wire) + } + + /// Decode a truncated fixture frame and store the error. + /// + /// # Errors + /// Returns an error if the codec is not configured. + pub fn decode_truncated_fixture(&mut self) -> TestResult { + let wire = wireframe_testing::truncated_hotline_header(); + self.decode_fixture(wire) + } + + /// Decode correlated fixture frames and store the results. + /// + /// # Errors + /// Returns an error if the codec is not configured or decoding fails + /// unexpectedly. + pub fn decode_correlated_fixture(&mut self) -> TestResult { + let wire = wireframe_testing::correlated_hotline_wire(42, &[b"a", b"b", b"c"]); + self.decode_fixture(wire) + } + + /// Decode `wire` with the configured codec, storing frames or error. + fn decode_fixture(&mut self, wire: Vec) -> TestResult { + self.decoded_frames.clear(); + self.decode_error = None; + let codec = self.codec.as_ref().ok_or("codec not configured")?; + match wireframe_testing::decode_frames_with_codec(codec, wire) { + Ok(frames) => self.decoded_frames = frames, + Err(e) => self.decode_error = Some(e), + } + Ok(()) + } + + /// Verify the decoded payload matches the expected fixture input. + /// + /// # Errors + /// Returns an error if the assertion fails. + pub fn verify_payload_matches(&self) -> TestResult { + if self.decoded_frames.len() != 1 { + return Err(format!("expected 1 frame, got {}", self.decoded_frames.len()).into()); + } + let frame = self + .decoded_frames + .first() + .ok_or("expected at least one frame")?; + if frame.payload.as_ref() != b"fixture-payload" { + return Err(format!( + "payload mismatch: expected b\"fixture-payload\", got {:?}", + frame.payload.as_ref() + ) + .into()); + } + Ok(()) + } + + /// Verify the decoder produced an `InvalidData` error for oversized + /// frames. + /// + /// # Errors + /// Returns an error if no decode error was captured or it does not + /// contain the expected message. + pub fn verify_invalid_data_error(&self) -> TestResult { + self.verify_error_message_contains("payload too large") + } + + /// Verify the decoder produced a "bytes remaining" error for truncated + /// frames. + /// + /// # Errors + /// Returns an error if no decode error was captured or it does not + /// contain the expected message. + pub fn verify_bytes_remaining_error(&self) -> TestResult { + self.verify_error_message_contains("bytes remaining") + } + + /// Verify the captured decode error contains `expected_substring`. + fn verify_error_message_contains(&self, expected_substring: &str) -> TestResult { + let err = self + .decode_error + .as_ref() + .ok_or("expected a decode error but decoding succeeded")?; + if !err.to_string().contains(expected_substring) { + return Err(format!("expected '{expected_substring}' error, got: {err}").into()); + } + Ok(()) + } + + /// Verify all decoded frames share the expected transaction identifier. + /// + /// # Errors + /// Returns an error if no frames were decoded or any frame has a + /// different transaction ID. + pub fn verify_correlated_transaction_ids(&self) -> TestResult { + if self.decoded_frames.len() != 3 { + return Err(format!("expected 3 frames, got {}", self.decoded_frames.len()).into()); + } + for (i, frame) in self.decoded_frames.iter().enumerate() { + if frame.transaction_id != 42 { + return Err(format!( + "frame {i}: expected transaction_id 42, got {}", + frame.transaction_id + ) + .into()); + } + } + Ok(()) + } +} diff --git a/tests/fixtures/mod.rs b/tests/fixtures/mod.rs index 304623fd..4ecbe014 100644 --- a/tests/fixtures/mod.rs +++ b/tests/fixtures/mod.rs @@ -11,6 +11,7 @@ pub mod client_request_hooks; pub mod client_runtime; pub mod client_streaming; pub mod codec_error; +pub mod codec_fixtures; pub mod codec_performance_benchmarks; pub mod codec_property_roundtrip; pub mod codec_stateful; diff --git a/tests/scenarios/codec_fixtures_scenarios.rs b/tests/scenarios/codec_fixtures_scenarios.rs new file mode 100644 index 00000000..5d4ff875 --- /dev/null +++ b/tests/scenarios/codec_fixtures_scenarios.rs @@ -0,0 +1,45 @@ +//! Scenario tests for codec fixture behaviours. + +use rstest_bdd_macros::scenario; + +use crate::fixtures::codec_fixtures::*; + +#[scenario( + path = "tests/features/codec_fixtures.feature", + name = "Valid fixture decodes to expected payload" +)] +#[expect( + unused_variables, + reason = "rstest-bdd wires steps via parameters without using them directly" +)] +fn valid_fixture_decodes(codec_fixtures_world: CodecFixturesWorld) {} + +#[scenario( + path = "tests/features/codec_fixtures.feature", + name = "Oversized fixture is rejected by decoder" +)] +#[expect( + unused_variables, + reason = "rstest-bdd wires steps via parameters without using them directly" +)] +fn oversized_fixture_rejected(codec_fixtures_world: CodecFixturesWorld) {} + +#[scenario( + path = "tests/features/codec_fixtures.feature", + name = "Truncated fixture produces a decode error" +)] +#[expect( + unused_variables, + reason = "rstest-bdd wires steps via parameters without using them directly" +)] +fn truncated_fixture_error(codec_fixtures_world: CodecFixturesWorld) {} + +#[scenario( + path = "tests/features/codec_fixtures.feature", + name = "Correlated fixtures share the same transaction identifier" +)] +#[expect( + unused_variables, + reason = "rstest-bdd wires steps via parameters without using them directly" +)] +fn correlated_fixture_ids(codec_fixtures_world: CodecFixturesWorld) {} diff --git a/tests/scenarios/mod.rs b/tests/scenarios/mod.rs index 29d769b0..a25fc68e 100644 --- a/tests/scenarios/mod.rs +++ b/tests/scenarios/mod.rs @@ -15,6 +15,7 @@ mod client_request_hooks_scenarios; mod client_runtime_scenarios; mod client_streaming_scenarios; mod codec_error_scenarios; +mod codec_fixtures_scenarios; mod codec_performance_benchmarks_scenarios; mod codec_property_roundtrip_scenarios; mod codec_stateful_scenarios; diff --git a/tests/steps/codec_fixtures_steps.rs b/tests/steps/codec_fixtures_steps.rs new file mode 100644 index 00000000..76551576 --- /dev/null +++ b/tests/steps/codec_fixtures_steps.rs @@ -0,0 +1,53 @@ +//! Step definitions for codec fixture behavioural tests. + +use rstest_bdd_macros::{given, then, when}; + +use crate::fixtures::codec_fixtures::{CodecFixturesWorld, TestResult}; + +#[given("a Hotline codec allowing fixtures up to {max_frame_length:usize} bytes")] +fn given_codec( + codec_fixtures_world: &mut CodecFixturesWorld, + max_frame_length: usize, +) -> TestResult { + codec_fixtures_world.configure_codec(max_frame_length) +} + +#[when("a valid fixture frame is decoded")] +fn when_valid_decoded(codec_fixtures_world: &mut CodecFixturesWorld) -> TestResult { + codec_fixtures_world.decode_valid_fixture() +} + +#[when("an oversized fixture frame is decoded")] +fn when_oversized_decoded(codec_fixtures_world: &mut CodecFixturesWorld) -> TestResult { + codec_fixtures_world.decode_oversized_fixture() +} + +#[when("a truncated fixture frame is decoded")] +fn when_truncated_decoded(codec_fixtures_world: &mut CodecFixturesWorld) -> TestResult { + codec_fixtures_world.decode_truncated_fixture() +} + +#[when("correlated fixture frames are decoded")] +fn when_correlated_decoded(codec_fixtures_world: &mut CodecFixturesWorld) -> TestResult { + codec_fixtures_world.decode_correlated_fixture() +} + +#[then("the decoded payload matches the fixture input")] +fn then_payload_matches(codec_fixtures_world: &mut CodecFixturesWorld) -> TestResult { + codec_fixtures_world.verify_payload_matches() +} + +#[then("the decoder reports an invalid data error")] +fn then_invalid_data(codec_fixtures_world: &mut CodecFixturesWorld) -> TestResult { + codec_fixtures_world.verify_invalid_data_error() +} + +#[then("the decoder reports bytes remaining on stream")] +fn then_bytes_remaining(codec_fixtures_world: &mut CodecFixturesWorld) -> TestResult { + codec_fixtures_world.verify_bytes_remaining_error() +} + +#[then("all frames have the expected transaction identifier")] +fn then_correlated_ids(codec_fixtures_world: &mut CodecFixturesWorld) -> TestResult { + codec_fixtures_world.verify_correlated_transaction_ids() +} diff --git a/tests/steps/mod.rs b/tests/steps/mod.rs index ef74c69f..726f89f0 100644 --- a/tests/steps/mod.rs +++ b/tests/steps/mod.rs @@ -11,6 +11,7 @@ mod client_request_hooks_steps; mod client_runtime_steps; mod client_streaming_steps; mod codec_error_steps; +mod codec_fixtures_steps; mod codec_performance_benchmarks_steps; mod codec_property_roundtrip_steps; mod codec_stateful_steps; diff --git a/wireframe_testing/src/helpers.rs b/wireframe_testing/src/helpers.rs index 2178c48e..516970ee 100644 --- a/wireframe_testing/src/helpers.rs +++ b/wireframe_testing/src/helpers.rs @@ -12,6 +12,7 @@ use wireframe::{ mod codec; mod codec_drive; mod codec_ext; +mod codec_fixtures; mod drive; mod payloads; mod runtime; @@ -56,6 +57,19 @@ pub use codec_drive::{ drive_with_codec_payloads_with_capacity_mut, }; pub use codec_ext::{decode_frames_with_codec, encode_payloads_with_codec, extract_payloads}; +pub use codec_fixtures::{ + MaxFrameLength, + PayloadLength, + TransactionId, + correlated_hotline_wire, + mismatched_total_size_wire, + oversized_hotline_wire, + sequential_hotline_wire, + truncated_hotline_header, + truncated_hotline_payload, + valid_hotline_frame, + valid_hotline_wire, +}; pub use drive::{ drive_with_frame, drive_with_frame_mut, diff --git a/wireframe_testing/src/helpers/codec_fixtures.rs b/wireframe_testing/src/helpers/codec_fixtures.rs new file mode 100644 index 00000000..fb4fd903 --- /dev/null +++ b/wireframe_testing/src/helpers/codec_fixtures.rs @@ -0,0 +1,316 @@ +//! Codec fixture functions for generating valid and invalid Hotline-framed +//! wire bytes. +//! +//! These helpers produce raw byte sequences suitable for feeding to +//! [`decode_frames_with_codec`](super::decode_frames_with_codec) or directly +//! to a `HotlineAdapter` decoder. Four categories of fixtures are provided: +//! +//! - **Valid frames** — well-formed wire bytes that decode cleanly. +//! - **Invalid frames** — wire bytes triggering decoder errors (oversized payloads, mismatched +//! sizes). +//! - **Incomplete frames** — truncated data that causes trailing-byte errors. +//! - **Correlation metadata** — multi-frame sequences with specific transaction IDs for correlation +//! testing. +//! +//! Fixtures construct raw bytes directly rather than using the tokio-util +//! encoder, ensuring they are independent of the encoder implementation and +//! can represent malformed data that the encoder would reject. + +use bytes::Bytes; +use wireframe::codec::{ + FrameCodec, + examples::{HotlineFrame, HotlineFrameCodec}, +}; + +/// A transaction identifier for Hotline protocol frames. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TransactionId(pub u32); + +impl From for TransactionId { + fn from(value: u32) -> Self { Self(value) } +} + +/// Maximum permitted frame length for a Hotline codec. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MaxFrameLength(pub usize); + +impl From for MaxFrameLength { + fn from(value: usize) -> Self { Self(value) } +} + +/// Payload length in bytes for a Hotline frame fixture. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PayloadLength(pub usize); + +impl From for PayloadLength { + fn from(value: usize) -> Self { Self(value) } +} + +/// Hotline header length in bytes: `data_size` (4) + `total_size` (4) + +/// `transaction_id` (4) + reserved (8). +const HEADER_LEN: usize = 20; + +/// Build a single valid Hotline frame as raw wire bytes. +/// +/// Writes the 20-byte Hotline header (`data_size`, `total_size`, +/// `transaction_id`, 8 reserved zero bytes) followed by `payload`. +/// +/// # Examples +/// +/// ```rust +/// use wireframe::codec::examples::HotlineFrameCodec; +/// use wireframe_testing::{decode_frames_with_codec, valid_hotline_wire}; +/// +/// let wire = valid_hotline_wire(b"hello", 7); +/// let codec = HotlineFrameCodec::new(4096); +/// let frames = decode_frames_with_codec(&codec, wire).expect("valid fixture should decode"); +/// assert_eq!(frames.len(), 1); +/// ``` +#[must_use] +pub fn valid_hotline_wire(payload: &[u8], transaction_id: impl Into) -> Vec { + let transaction_id = transaction_id.into().0; + build_hotline_wire(payload, transaction_id, payload.len(), true) +} + +/// Return a typed [`HotlineFrame`] with the given payload and transaction ID. +/// +/// Useful when a test needs to inspect frame metadata without going through +/// the wire-encode/decode cycle. +/// +/// # Examples +/// +/// ```rust +/// use wireframe_testing::valid_hotline_frame; +/// +/// let frame = valid_hotline_frame(b"data", 42); +/// assert_eq!(frame.transaction_id, 42); +/// assert_eq!(&*frame.payload, b"data"); +/// ``` +#[must_use] +pub fn valid_hotline_frame( + payload: &[u8], + transaction_id: impl Into, +) -> HotlineFrame { + let transaction_id = transaction_id.into().0; + let codec = HotlineFrameCodec::new(payload.len()); + let mut frame = codec.wrap_payload(Bytes::copy_from_slice(payload)); + frame.transaction_id = transaction_id; + frame +} + +/// Build a Hotline frame whose `data_size` exceeds `max_frame_length` by one +/// byte. +/// +/// The Hotline decoder rejects frames where `data_size > max_frame_length` +/// with an `InvalidData("payload too large")` error. +/// +/// # Examples +/// +/// ```rust +/// use wireframe::codec::examples::HotlineFrameCodec; +/// use wireframe_testing::{decode_frames_with_codec, oversized_hotline_wire}; +/// +/// let wire = oversized_hotline_wire(64); +/// let codec = HotlineFrameCodec::new(64); +/// let err = +/// decode_frames_with_codec(&codec, wire).expect_err("oversized frame should be rejected"); +/// assert!(err.to_string().contains("payload too large")); +/// ``` +#[must_use] +pub fn oversized_hotline_wire(max_frame_length: impl Into) -> Vec { + let max_frame_length = max_frame_length.into().0; + let oversized_len = max_frame_length.saturating_add(1); + build_hotline_wire(&vec![0xab; oversized_len], 0, oversized_len, true) +} + +/// Build a Hotline frame with a mismatched `total_size` field. +/// +/// The header's `total_size` is set to `data_size + 21` (one byte more than +/// the correct value of `data_size + 20`). The Hotline decoder rejects this +/// with an `InvalidData("invalid total size")` error. +/// +/// # Examples +/// +/// ```rust +/// use wireframe::codec::examples::HotlineFrameCodec; +/// use wireframe_testing::{decode_frames_with_codec, mismatched_total_size_wire}; +/// +/// let wire = mismatched_total_size_wire(b"test"); +/// let codec = HotlineFrameCodec::new(4096); +/// let err = decode_frames_with_codec(&codec, wire) +/// .expect_err("mismatched total_size should be rejected"); +/// assert!(err.to_string().contains("invalid total size")); +/// ``` +#[must_use] +pub fn mismatched_total_size_wire(payload: &[u8]) -> Vec { + build_hotline_wire(payload, 0, payload.len(), false) +} + +/// Return fewer than 20 bytes — a truncated Hotline header. +/// +/// The Hotline decoder returns `Ok(None)` for this input (not enough bytes +/// to parse the header). When passed to +/// [`decode_frames_with_codec`](super::decode_frames_with_codec), the +/// `decode_eof` call detects unconsumed bytes and produces an +/// `InvalidData` error containing "bytes remaining". +/// +/// # Examples +/// +/// ```rust +/// use wireframe::codec::examples::HotlineFrameCodec; +/// use wireframe_testing::{decode_frames_with_codec, truncated_hotline_header}; +/// +/// let wire = truncated_hotline_header(); +/// let codec = HotlineFrameCodec::new(4096); +/// let err = decode_frames_with_codec(&codec, wire) +/// .expect_err("truncated header should cause a decode error"); +/// assert!(err.to_string().contains("bytes remaining")); +/// ``` +#[must_use] +pub fn truncated_hotline_header() -> Vec { + // 10 bytes — enough to look like the start of a header but too short + // for the decoder to extract the full 20-byte header. + vec![0; 10] +} + +/// Return a valid Hotline header claiming `payload_len` bytes of payload, +/// but provide only half the payload bytes. +/// +/// The Hotline decoder returns `Ok(None)` because the buffer is shorter than +/// `total_size`. When passed to +/// [`decode_frames_with_codec`](super::decode_frames_with_codec), the +/// `decode_eof` call detects unconsumed bytes and produces an +/// `InvalidData` error containing "bytes remaining". +/// +/// # Examples +/// +/// ```rust +/// use wireframe::codec::examples::HotlineFrameCodec; +/// use wireframe_testing::{decode_frames_with_codec, truncated_hotline_payload}; +/// +/// let wire = truncated_hotline_payload(100); +/// let codec = HotlineFrameCodec::new(4096); +/// let err = decode_frames_with_codec(&codec, wire) +/// .expect_err("truncated payload should cause a decode error"); +/// assert!(err.to_string().contains("bytes remaining")); +/// ``` +#[must_use] +pub fn truncated_hotline_payload(payload_len: impl Into) -> Vec { + // Clamp to at least 1 so the header always claims more payload than + // the buffer provides, guaranteeing a genuinely truncated frame. + let payload_len = payload_len.into().0.max(1); + let data_size = u32_from_usize(payload_len); + let total_size = u32_from_usize(payload_len.saturating_add(HEADER_LEN)); + + let half_payload = payload_len / 2; + let mut buf = Vec::with_capacity(HEADER_LEN + half_payload); + buf.extend_from_slice(&data_size.to_be_bytes()); + buf.extend_from_slice(&total_size.to_be_bytes()); + buf.extend_from_slice(&0u32.to_be_bytes()); // transaction_id + buf.extend_from_slice(&[0u8; 8]); // reserved + buf.extend_from_slice(&vec![0xcc; half_payload]); + buf +} + +/// Encode multiple Hotline frames sharing the same `transaction_id`. +/// +/// Suitable for verifying that correlation ID propagation works across frame +/// sequences. All frames in the returned byte vector carry the same +/// transaction identifier. +/// +/// # Examples +/// +/// ```rust +/// use wireframe::codec::examples::HotlineFrameCodec; +/// use wireframe_testing::{correlated_hotline_wire, decode_frames_with_codec}; +/// +/// let wire = correlated_hotline_wire(42, &[b"a", b"b"]); +/// let codec = HotlineFrameCodec::new(4096); +/// let frames = decode_frames_with_codec(&codec, wire).expect("correlated fixtures should decode"); +/// assert_eq!(frames.len(), 2); +/// assert!(frames.iter().all(|f| f.transaction_id == 42)); +/// ``` +#[must_use] +pub fn correlated_hotline_wire( + transaction_id: impl Into, + payloads: &[&[u8]], +) -> Vec { + let transaction_id = transaction_id.into().0; + let mut buf = Vec::new(); + for payload in payloads { + buf.extend_from_slice(&valid_hotline_wire(payload, transaction_id)); + } + buf +} + +/// Encode multiple Hotline frames with incrementing transaction IDs. +/// +/// The first frame carries `base_transaction_id`, the second +/// `base_transaction_id + 1`, and so on. Suitable for verifying frame +/// ordering or sequential correlation. +/// +/// # Examples +/// +/// ```rust +/// use wireframe::codec::examples::HotlineFrameCodec; +/// use wireframe_testing::{decode_frames_with_codec, sequential_hotline_wire}; +/// +/// let wire = sequential_hotline_wire(10, &[b"x", b"y", b"z"]); +/// let codec = HotlineFrameCodec::new(4096); +/// let frames = decode_frames_with_codec(&codec, wire).expect("sequential fixtures should decode"); +/// assert_eq!(frames.len(), 3); +/// ``` +#[must_use] +pub fn sequential_hotline_wire( + base_transaction_id: impl Into, + payloads: &[&[u8]], +) -> Vec { + let base_transaction_id = base_transaction_id.into().0; + let mut buf = Vec::new(); + for (i, payload) in payloads.iter().enumerate() { + #[expect( + clippy::cast_possible_truncation, + reason = "fixture payloads slice length will not exceed u32::MAX" + )] + let tid = base_transaction_id.wrapping_add(i as u32); + buf.extend_from_slice(&valid_hotline_wire(payload, tid)); + } + buf +} + +// ── Internal helpers ──────────────────────────────────────────────────── + +/// Construct a Hotline wire frame with explicit control over header fields. +/// +/// When `correct_total_size` is `true`, `total_size` is set to +/// `data_size + HEADER_LEN`. When `false`, `total_size` is set to +/// `data_size + HEADER_LEN + 1` (deliberately wrong). +fn build_hotline_wire( + payload: &[u8], + transaction_id: impl Into, + data_size: usize, + correct_total_size: bool, +) -> Vec { + let transaction_id = transaction_id.into().0; + let data_size_u32 = u32_from_usize(data_size); + let total_size = if correct_total_size { + u32_from_usize(data_size.saturating_add(HEADER_LEN)) + } else { + // Off by one — triggers "invalid total size" in the decoder. + u32_from_usize(data_size.saturating_add(HEADER_LEN).saturating_add(1)) + }; + + let mut buf = Vec::with_capacity(HEADER_LEN + payload.len()); + buf.extend_from_slice(&data_size_u32.to_be_bytes()); + buf.extend_from_slice(&total_size.to_be_bytes()); + buf.extend_from_slice(&transaction_id.to_be_bytes()); + buf.extend_from_slice(&[0u8; 8]); // reserved + buf.extend_from_slice(payload); + buf +} + +/// Convert a `usize` to `u32`, saturating at `u32::MAX`. +/// +/// Fixture payloads are always small enough to fit in `u32`, but we avoid a +/// truncating cast to satisfy Clippy. +fn u32_from_usize(value: usize) -> u32 { u32::try_from(value).unwrap_or(u32::MAX) } diff --git a/wireframe_testing/src/lib.rs b/wireframe_testing/src/lib.rs index 9575a06f..150bafd8 100644 --- a/wireframe_testing/src/lib.rs +++ b/wireframe_testing/src/lib.rs @@ -27,8 +27,12 @@ pub mod multi_packet; pub use echo_server::{ServerMode, process_frame}; pub use helpers::{ + MaxFrameLength, + PayloadLength, TEST_MAX_FRAME, TestSerializer, + TransactionId, + correlated_hotline_wire, decode_frames, decode_frames_with_codec, decode_frames_with_max, @@ -50,9 +54,16 @@ pub use helpers::{ encode_frame, encode_payloads_with_codec, extract_payloads, + mismatched_total_size_wire, new_test_codec, + oversized_hotline_wire, run_app, run_with_duplex_server, + sequential_hotline_wire, + truncated_hotline_header, + truncated_hotline_payload, + valid_hotline_frame, + valid_hotline_wire, }; pub use integration_helpers::{ CommonTestEnvelope,