Files
tcp-ingestion/.planning/phase-1-telemetry/09-fixture-suite.md
T
julian c8a5f4cd68 Add Phase 1 and Phase 2 planning documents
ROADMAP plus granular task files per phase. Phase 1 (12 tasks + 1.13
device authority) covers Codec 8/8E/16 telemetry ingestion; Phase 2
(6 tasks) covers Codec 12/14 outbound commands; Phase 3 enumerates
deferred items.
2026-04-30 15:50:49 +02:00

7.0 KiB
Raw Blame History

Task 1.9 — Fixture suite & testing strategy

Phase: 1 — Inbound telemetry Status: Not started 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:

{
  "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 07.
  • 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:

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 project's Teltonika decoder) before declaring done.

Done

(Fill in once complete.)