90d6a73a60
Tasks 1.1-1.9 marked done with their landing commit SHAs. Tasks 1.10 (observability), 1.12 (production hardening), and 1.13 (device authority) marked paused with explicit resume triggers — pilot deployment on real Teltonika hardware takes priority. Task 1.11 remains as next, in slimmed form for the pilot (no /readyz healthcheck since the metrics endpoint is part of paused 1.10).
157 lines
7.0 KiB
Markdown
157 lines
7.0 KiB
Markdown
# Task 1.9 — Fixture suite & testing strategy
|
||
|
||
**Phase:** 1 — Inbound telemetry
|
||
**Status:** 🟩 Done — landed in commit `381287b`
|
||
**Depends on:** 1.1
|
||
**Wiki refs:** `docs/wiki/sources/teltonika-ingestion-architecture.md` § 5.6, `docs/wiki/sources/teltonika-data-sending-protocols.md`
|
||
|
||
## Goal
|
||
|
||
Establish the fixture-based testing infrastructure and seed it with the canonical hex captures from the Teltonika documentation. **This is the only place where the parser's correctness is actually verified.** Bugs in binary protocol parsers are silent; tests are the defense.
|
||
|
||
## Deliverables
|
||
|
||
- `test/fixtures/teltonika/codec8/`, `test/fixtures/teltonika/codec8e/`, `test/fixtures/teltonika/codec16/` populated with at least:
|
||
- 3 captures from the canonical Teltonika doc (one per codec, with full parsed expectations).
|
||
- 1 synthetic edge case per codec (empty IO bag, max-size IO values, multi-record).
|
||
- Each fixture is a pair: `<name>.hex` (raw frame, hex-encoded with whitespace stripped) and `<name>.expected.json` (the expected `Position[]` after parsing).
|
||
- `test/fixtures/_loader.ts` — helpers:
|
||
- `loadFixture(path): { hex: Buffer; expected: Position[] }`
|
||
- `compareToExpected(actual: Position[], expected: Position[]): void` (deep-equals with `bigint`/`Buffer` aware comparator).
|
||
- A vitest test pattern that automatically picks up every fixture pair in a directory and generates a test per pair. So adding a new fixture file = a new test, no boilerplate.
|
||
- `test/fixtures/teltonika/README.md` documenting the format and how to add new captures.
|
||
|
||
## Specification
|
||
|
||
### Fixture format
|
||
|
||
`fixture-name.hex`:
|
||
```
|
||
000000000000003608010000016B40D8EA30
|
||
01000000000000000000000000000000010
|
||
5021503010101425E0F01F10000601A014E
|
||
0000000000000000010000C7CF
|
||
```
|
||
|
||
Whitespace and newlines are ignored. Implementer strips `[^0-9a-fA-F]` and parses with `Buffer.from(hex, 'hex')`.
|
||
|
||
`fixture-name.expected.json`:
|
||
```json
|
||
{
|
||
"positions": [
|
||
{
|
||
"device_id": "FIXTURE",
|
||
"timestamp": "2019-06-10T10:04:46.000Z",
|
||
"latitude": 0,
|
||
"longitude": 0,
|
||
"altitude": 0,
|
||
"angle": 0,
|
||
"speed": 0,
|
||
"satellites": 0,
|
||
"priority": 1,
|
||
"attributes": {
|
||
"21": 3,
|
||
"1": 1,
|
||
"66": 24079,
|
||
"241": 24602,
|
||
"78": "__bigint:0",
|
||
"__event": 1
|
||
}
|
||
}
|
||
],
|
||
"ack_record_count": 1
|
||
}
|
||
```
|
||
|
||
The `__bigint:` and `__buffer_b64:` prefixes are how the JSON file represents the special types. The loader decodes them into real `bigint` / `Buffer` instances before comparison.
|
||
|
||
`device_id` in fixtures is a placeholder (`"FIXTURE"`) because the captures don't include the IMEI — the codec parsers receive the IMEI from the framing layer's session context, not from the body itself.
|
||
|
||
### Bootstrap fixtures (must be present at end of this task)
|
||
|
||
From the canonical Teltonika doc (`docs/raw/Teltonika Data Sending Protocols - Teltonika Telematics Wiki.md`):
|
||
|
||
#### Codec 8
|
||
- `01-single-record-all-widths.hex`: 1st example — one record with N1=2, N2=1, N4=1, N8=1.
|
||
- `02-single-record-reduced.hex`: 2nd example — one record with N1=2, N2=1, N4=0, N8=0.
|
||
- `03-two-records.hex`: 3rd example — two records with minimal IO.
|
||
|
||
#### Codec 8 Extended
|
||
- `01-canonical.hex`: doc example — one record, N1=1, N2=1, N4=1, N8=2, NX=0.
|
||
|
||
#### Codec 16
|
||
- `01-canonical.hex`: doc example — two records with Generation Type `0x05`.
|
||
|
||
### Synthetic fixtures (must be present)
|
||
|
||
#### Codec 8
|
||
- `04-empty-io-bag.hex`: one record, N=0 (no IO elements). Smallest valid record.
|
||
- `05-multi-record-large.hex`: 10 records to exercise the loop and N1==N2 invariant.
|
||
|
||
#### Codec 8 Extended
|
||
- `02-nx-mixed.hex`: one record with NX=3, lengths 1, 8, 64.
|
||
- `03-nx-zero-length.hex`: one record with one NX entry of length 0.
|
||
- `04-nx-large-length.hex`: one record with one NX entry of length 300+ (verifies 16-bit length read).
|
||
|
||
#### Codec 16
|
||
- `02-each-generation-type.hex`: 8 records, one per Generation Type 0–7.
|
||
- `03-large-io-id.hex`: one record with an IO ID > 255 (e.g. `0x0400`).
|
||
|
||
### Test runner pattern
|
||
|
||
In `test/codec8.test.ts`, `codec8e.test.ts`, `codec16.test.ts`:
|
||
|
||
```ts
|
||
import { describe, it, expect } from 'vitest';
|
||
import { loadFixturesFromDir } from './fixtures/_loader';
|
||
import { codec8Handler } from '../src/adapters/teltonika/codec/data/codec8';
|
||
|
||
describe('Codec 8 parser', () => {
|
||
for (const fixture of loadFixturesFromDir('test/fixtures/teltonika/codec8')) {
|
||
it(`parses ${fixture.name}`, async () => {
|
||
const positions: Position[] = [];
|
||
const ctx = makeTestCtx(positions);
|
||
const result = await codec8Handler.handle(fixture.body, ctx);
|
||
expect(positions).toEqual(fixture.expected.positions);
|
||
expect(result.recordCount).toBe(fixture.expected.ack_record_count);
|
||
});
|
||
}
|
||
});
|
||
```
|
||
|
||
This pattern means **adding a new fixture file = a new test, automatically.** No editing the test file.
|
||
|
||
### CRC tests
|
||
|
||
A separate `test/crc.test.ts` covers `crc16Ibm` against:
|
||
- The canonical doc CRCs (each fixture's CRC computed over the body should match the trailing CRC bytes).
|
||
- A few hand-computed reference values (from online CRC-16/IBM calculators, recorded in the test).
|
||
- An empty buffer (`crc16Ibm(Buffer.alloc(0))` should return `0x0000`).
|
||
|
||
### Frame tests
|
||
|
||
`test/frame.test.ts`:
|
||
- IMEI handshake happy path.
|
||
- IMEI handshake malformed length.
|
||
- Frame envelope: bytes split across multiple `data` events.
|
||
- Frame envelope: CRC mismatch path returns the right outcome (no ACK, connection stays open).
|
||
- Frame envelope: unknown codec ID drops the connection.
|
||
|
||
## Acceptance criteria
|
||
|
||
- [ ] All bootstrap and synthetic fixtures listed above are present.
|
||
- [ ] `pnpm test` runs all fixture tests and they pass.
|
||
- [ ] `pnpm test --coverage` reports ≥ 90% line coverage for `src/adapters/teltonika/codec/`.
|
||
- [ ] Adding a new fixture pair to a codec's fixtures directory automatically produces a new test (verified manually by adding a temp fixture).
|
||
- [ ] The fixture README documents the format clearly enough that a new contributor can add a capture without reading the test code.
|
||
|
||
## Risks / open questions
|
||
|
||
- Where do real production captures come from? Until devices are streaming to a staging environment, we only have doc captures. Plan: record the first day of staging traffic into `tcpdump`-style captures, extract a few representative frames per device model, contribute them as fixtures with the model name in the filename. This step is a follow-up after staging deployment, not a Phase 1 blocker.
|
||
- Hex format vs binary `.bin` files: hex is reviewable in PRs and documented in the doc. Stick with hex.
|
||
- Confirming expected outputs: bootstrap fixtures' expected outputs come directly from the canonical doc's parsed tables. Synthetic fixture expectations are computed by hand and double-checked against the parser output once the parser is believed correct — circular if the parser is buggy. Mitigation: cross-check at least one synthetic fixture against an external Teltonika parser (e.g. the open-source [Traccar](https://github.com/traccar/traccar) project's Teltonika decoder) before declaring done.
|
||
|
||
## Done
|
||
|
||
(Fill in once complete.)
|