Skip to main content

hdm_am/
error.rs

1//! Error taxonomy with recovery semantics.
2//!
3//! The crate exposes a single [`enum@Error`] type that the consumer matches on. Each variant carries
4//! enough information to make a recovery decision via [`Error::is_retryable`],
5//! [`Error::requires_relogin`], and [`Error::requires_reconnect`].
6//!
7//! Server-side error codes from the spec (§4.10) are modelled as [`ServerErrorKind`] — an
8//! exhaustive enum covering every documented response code, plus an `Unknown(u16)` variant for
9//! forward compatibility with future spec revisions.
10
11use thiserror::Error;
12
13/// All errors the HDM client can produce.
14#[derive(Debug, Error)]
15#[non_exhaustive]
16pub enum Error {
17    /// Underlying transport (TCP, mock, etc.) failure.
18    ///
19    /// Includes timeouts, connection resets, DNS failures. Recovery: reconnect + retry, possibly
20    /// with backoff.
21    #[error("transport: {0}")]
22    Transport(#[from] std::io::Error),
23
24    /// HDM returned a non-success response code. See [`ServerErrorKind`] for the categorisation.
25    #[error("server error: code {code} ({kind:?})")]
26    Server {
27        /// Raw numeric code as returned in the response header (e.g. `141`, `185`).
28        code: u16,
29        /// Categorised kind. `ServerErrorKind::Unknown(code)` for codes outside the documented set.
30        kind: ServerErrorKind,
31    },
32
33    /// 3DES decryption or padding validation failed.
34    ///
35    /// In practice this almost always means the session key is stale (server-side session
36    /// timed out, or sequence numbers drifted). Recovery: re-login.
37    #[error("cryptographic: {0}")]
38    Crypto(#[from] CryptoError),
39
40    /// Response payload could not be parsed as JSON, or required fields were missing.
41    ///
42    /// Usually indicates spec drift between the crate and the device, or a corrupted payload
43    /// that survived decryption. Not recoverable without intervention.
44    #[error("response decode: {0}")]
45    Decode(serde_json::Error),
46
47    /// Request payload could not be serialised to JSON. Indicates a programming bug — the
48    /// crate's request structs are always serialisable by construction.
49    #[error("request encode: {0}")]
50    Encode(serde_json::Error),
51
52    /// Operation requires an active session but [`crate::Client::login`] has not been called
53    /// successfully.
54    #[error("operation requires login")]
55    NotLoggedIn,
56
57    /// A request payload exceeded the protocol's 2-byte length field (65 535 bytes).
58    #[error("request payload too large ({len} bytes, max 65 535)")]
59    PayloadTooLarge {
60        /// The actual size that overflowed.
61        len: usize,
62    },
63
64    /// [`crate::identify`] reached a responsive endpoint, but its reply did not begin with the
65    /// HDM protocol version — some other TCP service is listening on that address. Distinct from
66    /// [`Self::Transport`] (nothing answered) so a discovery sweep can tell "wrong service" from
67    /// "unreachable".
68    #[error("endpoint is not an HDM (reported protocol version {protocol_version:?})")]
69    NotHdm {
70        /// The first two response bytes the endpoint sent, interpreted as a protocol version.
71        protocol_version: (u8, u8),
72    },
73}
74
75impl Error {
76    /// Whether retrying the *same operation* with no state change might succeed.
77    ///
78    /// True for transient transport failures (timeout, broken pipe) and a small set of
79    /// server-side conditions that resolve themselves (e.g. printer-out-of-paper after operator
80    /// intervention). False for logical errors that need data fixes.
81    #[must_use]
82    pub fn is_retryable(&self) -> bool {
83        match self {
84            Self::Transport(io) => is_retryable_io_kind(io.kind()),
85            Self::Server { kind, .. } => kind.is_retryable(),
86            _ => false,
87        }
88    }
89
90    /// Whether the client should drop the current session and call [`crate::Client::login`]
91    /// again before the next operation.
92    #[must_use]
93    pub const fn requires_relogin(&self) -> bool {
94        match self {
95            Self::NotLoggedIn | Self::Crypto(_) => true,
96            Self::Server { kind, .. } => kind.requires_relogin(),
97            _ => false,
98        }
99    }
100
101    /// Whether the underlying TCP connection is in an unrecoverable state and must be
102    /// re-established.
103    #[must_use]
104    pub const fn requires_reconnect(&self) -> bool {
105        match self {
106            Self::Transport(_) => true,
107            Self::Server { kind, .. } => kind.is_fatal_for_connection(),
108            _ => false,
109        }
110    }
111}
112
113const fn is_retryable_io_kind(kind: std::io::ErrorKind) -> bool {
114    matches!(
115        kind,
116        std::io::ErrorKind::TimedOut
117            | std::io::ErrorKind::WouldBlock
118            | std::io::ErrorKind::Interrupted
119            | std::io::ErrorKind::ConnectionAborted
120            | std::io::ErrorKind::ConnectionReset
121            | std::io::ErrorKind::BrokenPipe
122    )
123}
124
125/// Cryptographic failure modes encountered when encrypting requests or decrypting responses.
126#[derive(Debug, Error, PartialEq, Eq)]
127#[non_exhaustive]
128pub enum CryptoError {
129    /// 3DES key was not 24 bytes long. Indicates a programming bug — the crate's internals
130    /// always produce 24-byte keys.
131    #[error("3DES key must be exactly 24 bytes")]
132    InvalidKeyLength,
133
134    /// Ciphertext length was not a multiple of the 3DES block size (8 bytes).
135    #[error("ciphertext length is not a multiple of 8")]
136    InvalidBlockSize,
137
138    /// PKCS7 padding verification failed during decryption. Usually means the session key is
139    /// stale or the ciphertext was tampered with mid-flight.
140    #[error("PKCS7 padding is malformed (likely stale session key)")]
141    BadPadding,
142
143    /// Session key returned by the server did not Base64-decode cleanly.
144    #[error("session key Base64 decode failed: {0}")]
145    SessionKeyBase64(#[from] base64::DecodeError),
146}
147
148/// Categorised server response codes per spec §4.10.
149///
150/// Every documented code maps to a named variant. Codes outside the documented set become
151/// `Unknown(u16)` so that future spec revisions don't break existing builds.
152///
153/// The variants are grouped roughly by the spec's own categories: generic errors, login errors,
154/// receipt-print errors. Recovery semantics are exposed via [`Self::is_retryable`],
155/// [`Self::requires_relogin`], and [`Self::is_fatal_for_connection`].
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157#[non_exhaustive]
158pub enum ServerErrorKind {
159    // --- General (§4.10 "Ընդհանուր") ---
160    /// `500` — Internal HDM error.
161    InternalHdmError,
162    /// `400` — Request error (request not processed).
163    BadRequest,
164    /// `402` — Bad protocol version.
165    BadProtocolVersion,
166    /// `403` — Unauthorised connection (HDM-registered IP doesn't match the caller's).
167    UnauthorizedConnection,
168    /// `404` — Bad operation code in the header.
169    BadOperationCode,
170    /// `101` — Cryptographic encryption error.
171    CryptographicError,
172    /// `102` — Session-key encryption error.
173    SessionEncryptionError,
174    /// `103` — Header format error.
175    HeaderFormatError,
176    /// `104` — Request sequence number error.
177    BadSequenceNumber,
178    /// `105` — JSON format error.
179    BadJsonFormat,
180    /// `141` — Last receipt archive is empty.
181    LastReceiptArchiveEmpty,
182    /// `142` — Last receipt belongs to a different user.
183    LastReceiptDifferentUser,
184    /// `143` — Generic print error.
185    GenericPrintError,
186    /// `144` — Printer initialisation error.
187    PrinterInitError,
188    /// `145` — Printer is out of paper.
189    PrinterOutOfPaper,
190
191    // --- Operator / login errors ---
192    /// `111` — Operator password error.
193    BadOperatorPassword,
194    /// `112` — No such operator (role mismatch, inactive user, or not registered).
195    NoSuchOperator,
196    /// `113` — Operator is inactive.
197    InactiveOperator,
198    /// `121` — Print error (during login flow).
199    GenericLoginPrintError,
200
201    // --- Receipt-print errors ---
202    /// `151` — No such department (or operator lacks access).
203    NoSuchDepartment,
204    /// `152` — Paid amount is less than the total.
205    PaidLessThanTotal,
206    /// `153` — Receipt amount exceeds the configured limit.
207    AmountExceedsLimit,
208    /// `154` — Receipt amount must be positive.
209    AmountMustBePositive,
210    /// `155` — HDM synchronisation required before this operation can succeed.
211    HdmSyncRequired,
212    /// `156` — Synchronisation not completed.
213    SyncIncomplete,
214    /// `157` — Bad return-receipt number.
215    BadReturnReceiptNumber,
216    /// `158` — Receipt already returned.
217    ReceiptAlreadyReturned,
218    /// `159` — Non-positive product price or quantity.
219    NonPositiveProductPrice,
220    /// `160` — Discount percent out of range (must be 0..100).
221    DiscountPercentOutOfRange,
222    /// `161` — Bad product code.
223    BadProductCode,
224    /// `162` — Bad product name.
225    BadProductName,
226    /// `163` — Empty product unit-of-measure.
227    EmptyProductUnit,
228    /// `164` — Cashless payment failure.
229    CashlessPaymentFailure,
230    /// `165` — Product price cannot be zero.
231    ZeroProductPrice,
232    /// `166` — Final price calculation error.
233    FinalPriceCalculationError,
234    /// `167` — Card amount is greater than the receipt's total.
235    CardAmountExceedsTotal,
236    /// `168` — Card amount covers the total (cash amount is redundant).
237    CardAmountCoversAllCashRedundant,
238    /// `169` — Fiscal-report filter conflict (more than one filter sent).
239    ReportFiltersError,
240    /// `170` — Fiscal-report time range exceeds 2 months.
241    ReportTimeRangeError,
242    /// `171` — Invalid item price value.
243    InvalidItemPrice,
244    /// `172` — Wrong receipt type (not product/simple/prepayment).
245    WrongReceiptType,
246    /// `173` — Invalid discount type.
247    InvalidDiscountType,
248    /// `174` — Return-target receipt does not exist.
249    ReturnReceiptNotFound,
250    /// `175` — Bad registration number on the return-target receipt.
251    BadReturnReceiptRegNum,
252    /// `176` — Last receipt does not exist.
253    LastReceiptNotFound,
254    /// `177` — Return not supported for this receipt type.
255    ReturnNotSupportedForType,
256    /// `178` — Requested return amount cannot be processed.
257    AmountCannotBeReturned,
258    /// `179` — Partial-payment receipt must be returned in full.
259    PartialMustBeReturnedInFull,
260    /// `180` — Full-return amount exceeds available.
261    FullReturnExceedsAmount,
262    /// `181` — Bad return-product quantity.
263    BadReturnProductQuantity,
264    /// `182` — Return receipt is itself a return-type receipt.
265    ReturnReceiptIsReturn,
266    /// `183` — Bad ATG/ADG code (see `taxservice.am` for the canonical list).
267    BadAtgCode,
268    /// `184` — Inappropriate prepayment-return request.
269    InvalidPrepaymentReturn,
270    /// `185` — Cannot return partial-payment receipt; HDM software sync required.
271    PartialReturnSyncRequired,
272    /// `186` — Bad amount in prepayment case.
273    BadPrepaymentAmount,
274    /// `187` — Bad list in prepayment case.
275    BadPrepaymentList,
276    /// `188` — Bad amounts in general.
277    BadAmounts,
278    /// `189` — Bad rounding.
279    BadRounding,
280    /// `190` — Payment not available.
281    PaymentUnavailable,
282    /// `191` — Cash in/out amount must be greater than zero.
283    NonPositiveCashAmount,
284    /// `192` — ATG code is mandatory.
285    AtgCodeRequired,
286    /// `193` — Bad partner-TIN format.
287    BadPartnerTinFormat,
288    /// `194` — eMark codes not allowed in prepayment receipts.
289    EmarksNotAllowedInPrepayment,
290    /// `195` — Bad eMark code format.
291    BadEmarkFormat,
292    /// `196` — Other unknown error documented by the spec.
293    OtherUnknownError,
294
295    /// A vendor/firmware-specific code that is not part of the SRC spec §4.10. Kept in its own
296    /// [`VendorErrorKind`] enum so spec codes and vendor extensions never blur together; the two
297    /// sets are joined only here and in [`Self::from_code`].
298    Vendor(VendorErrorKind),
299
300    /// Response code present in neither spec v0.7.3 nor the known vendor set. Carried verbatim for
301    /// forward compatibility.
302    Unknown(u16),
303}
304
305/// Vendor/firmware-specific response codes observed on real devices but absent from the SRC
306/// integration spec (§4.10).
307///
308/// These are deliberately segregated from [`ServerErrorKind`]'s documented variants: the spec is
309/// the stable contract, whereas these depend on a particular firmware. Descriptions come from the
310/// device's own built-in response-code reference.
311#[derive(Debug, Clone, Copy, PartialEq, Eq)]
312#[non_exhaustive]
313pub enum VendorErrorKind {
314    /// `408` — The HDM's own UI is in use (a user is operating the device's screens), so
315    /// external-program access is temporarily blocked. Newland N950:
316    /// "Գործողություն է կատարվում ՀԴՄ-ի էջերում: Արտաքին ծրագրի աշխատանքը բլոկավորված է:".
317    /// Transient — retry once the device screen is idle.
318    ExternalProgramBlocked,
319
320    /// `503` — Mismatch in the data received from the (tax-authority) server. Newland N950:
321    /// "Սերվերից ստացված տվյալների անհամապատասխանություն". Indicates the device's view of a
322    /// record disagrees with the central server; not resolved by a blind retry.
323    ServerDataMismatch,
324}
325
326impl VendorErrorKind {
327    /// Map a numeric response code to a vendor variant, if it is a known vendor code.
328    #[must_use]
329    pub const fn from_code(code: u16) -> Option<Self> {
330        match code {
331            408 => Some(Self::ExternalProgramBlocked),
332            503 => Some(Self::ServerDataMismatch),
333            _ => None,
334        }
335    }
336
337    /// The numeric code for this vendor variant.
338    #[must_use]
339    pub const fn code(self) -> u16 {
340        match self {
341            Self::ExternalProgramBlocked => 408,
342            Self::ServerDataMismatch => 503,
343        }
344    }
345
346    /// Whether retrying unchanged might succeed. Only the transient "UI busy" condition qualifies.
347    #[must_use]
348    pub const fn is_retryable(self) -> bool {
349        matches!(self, Self::ExternalProgramBlocked)
350    }
351}
352
353impl ServerErrorKind {
354    /// Map a numeric response code to its categorised variant.
355    #[must_use]
356    pub const fn from_code(code: u16) -> Self {
357        match code {
358            500 => Self::InternalHdmError,
359            400 => Self::BadRequest,
360            402 => Self::BadProtocolVersion,
361            403 => Self::UnauthorizedConnection,
362            404 => Self::BadOperationCode,
363            101 => Self::CryptographicError,
364            102 => Self::SessionEncryptionError,
365            103 => Self::HeaderFormatError,
366            104 => Self::BadSequenceNumber,
367            105 => Self::BadJsonFormat,
368            141 => Self::LastReceiptArchiveEmpty,
369            142 => Self::LastReceiptDifferentUser,
370            143 => Self::GenericPrintError,
371            144 => Self::PrinterInitError,
372            145 => Self::PrinterOutOfPaper,
373            111 => Self::BadOperatorPassword,
374            112 => Self::NoSuchOperator,
375            113 => Self::InactiveOperator,
376            121 => Self::GenericLoginPrintError,
377            151 => Self::NoSuchDepartment,
378            152 => Self::PaidLessThanTotal,
379            153 => Self::AmountExceedsLimit,
380            154 => Self::AmountMustBePositive,
381            155 => Self::HdmSyncRequired,
382            156 => Self::SyncIncomplete,
383            157 => Self::BadReturnReceiptNumber,
384            158 => Self::ReceiptAlreadyReturned,
385            159 => Self::NonPositiveProductPrice,
386            160 => Self::DiscountPercentOutOfRange,
387            161 => Self::BadProductCode,
388            162 => Self::BadProductName,
389            163 => Self::EmptyProductUnit,
390            164 => Self::CashlessPaymentFailure,
391            165 => Self::ZeroProductPrice,
392            166 => Self::FinalPriceCalculationError,
393            167 => Self::CardAmountExceedsTotal,
394            168 => Self::CardAmountCoversAllCashRedundant,
395            169 => Self::ReportFiltersError,
396            170 => Self::ReportTimeRangeError,
397            171 => Self::InvalidItemPrice,
398            172 => Self::WrongReceiptType,
399            173 => Self::InvalidDiscountType,
400            174 => Self::ReturnReceiptNotFound,
401            175 => Self::BadReturnReceiptRegNum,
402            176 => Self::LastReceiptNotFound,
403            177 => Self::ReturnNotSupportedForType,
404            178 => Self::AmountCannotBeReturned,
405            179 => Self::PartialMustBeReturnedInFull,
406            180 => Self::FullReturnExceedsAmount,
407            181 => Self::BadReturnProductQuantity,
408            182 => Self::ReturnReceiptIsReturn,
409            183 => Self::BadAtgCode,
410            184 => Self::InvalidPrepaymentReturn,
411            185 => Self::PartialReturnSyncRequired,
412            186 => Self::BadPrepaymentAmount,
413            187 => Self::BadPrepaymentList,
414            188 => Self::BadAmounts,
415            189 => Self::BadRounding,
416            190 => Self::PaymentUnavailable,
417            191 => Self::NonPositiveCashAmount,
418            192 => Self::AtgCodeRequired,
419            193 => Self::BadPartnerTinFormat,
420            194 => Self::EmarksNotAllowedInPrepayment,
421            195 => Self::BadEmarkFormat,
422            196 => Self::OtherUnknownError,
423            other => match VendorErrorKind::from_code(other) {
424                Some(vendor) => Self::Vendor(vendor),
425                None => Self::Unknown(other),
426            },
427        }
428    }
429
430    /// Reverse mapping back to the numeric code.
431    #[must_use]
432    pub const fn code(self) -> u16 {
433        match self {
434            Self::InternalHdmError => 500,
435            Self::BadRequest => 400,
436            Self::BadProtocolVersion => 402,
437            Self::UnauthorizedConnection => 403,
438            Self::BadOperationCode => 404,
439            Self::CryptographicError => 101,
440            Self::SessionEncryptionError => 102,
441            Self::HeaderFormatError => 103,
442            Self::BadSequenceNumber => 104,
443            Self::BadJsonFormat => 105,
444            Self::LastReceiptArchiveEmpty => 141,
445            Self::LastReceiptDifferentUser => 142,
446            Self::GenericPrintError => 143,
447            Self::PrinterInitError => 144,
448            Self::PrinterOutOfPaper => 145,
449            Self::BadOperatorPassword => 111,
450            Self::NoSuchOperator => 112,
451            Self::InactiveOperator => 113,
452            Self::GenericLoginPrintError => 121,
453            Self::NoSuchDepartment => 151,
454            Self::PaidLessThanTotal => 152,
455            Self::AmountExceedsLimit => 153,
456            Self::AmountMustBePositive => 154,
457            Self::HdmSyncRequired => 155,
458            Self::SyncIncomplete => 156,
459            Self::BadReturnReceiptNumber => 157,
460            Self::ReceiptAlreadyReturned => 158,
461            Self::NonPositiveProductPrice => 159,
462            Self::DiscountPercentOutOfRange => 160,
463            Self::BadProductCode => 161,
464            Self::BadProductName => 162,
465            Self::EmptyProductUnit => 163,
466            Self::CashlessPaymentFailure => 164,
467            Self::ZeroProductPrice => 165,
468            Self::FinalPriceCalculationError => 166,
469            Self::CardAmountExceedsTotal => 167,
470            Self::CardAmountCoversAllCashRedundant => 168,
471            Self::ReportFiltersError => 169,
472            Self::ReportTimeRangeError => 170,
473            Self::InvalidItemPrice => 171,
474            Self::WrongReceiptType => 172,
475            Self::InvalidDiscountType => 173,
476            Self::ReturnReceiptNotFound => 174,
477            Self::BadReturnReceiptRegNum => 175,
478            Self::LastReceiptNotFound => 176,
479            Self::ReturnNotSupportedForType => 177,
480            Self::AmountCannotBeReturned => 178,
481            Self::PartialMustBeReturnedInFull => 179,
482            Self::FullReturnExceedsAmount => 180,
483            Self::BadReturnProductQuantity => 181,
484            Self::ReturnReceiptIsReturn => 182,
485            Self::BadAtgCode => 183,
486            Self::InvalidPrepaymentReturn => 184,
487            Self::PartialReturnSyncRequired => 185,
488            Self::BadPrepaymentAmount => 186,
489            Self::BadPrepaymentList => 187,
490            Self::BadAmounts => 188,
491            Self::BadRounding => 189,
492            Self::PaymentUnavailable => 190,
493            Self::NonPositiveCashAmount => 191,
494            Self::AtgCodeRequired => 192,
495            Self::BadPartnerTinFormat => 193,
496            Self::EmarksNotAllowedInPrepayment => 194,
497            Self::BadEmarkFormat => 195,
498            Self::OtherUnknownError => 196,
499            Self::Vendor(vendor) => vendor.code(),
500            Self::Unknown(c) => c,
501        }
502    }
503
504    /// Whether retrying the same operation might succeed without changing state.
505    ///
506    /// Conservatively false for most variants. True only for transient conditions
507    /// (printer-out-of-paper, sync-incomplete, or the HDM's UI being momentarily busy) where the
508    /// situation may clear itself between attempts.
509    #[must_use]
510    pub const fn is_retryable(self) -> bool {
511        match self {
512            Self::PrinterOutOfPaper | Self::SyncIncomplete => true,
513            Self::Vendor(vendor) => vendor.is_retryable(),
514            _ => false,
515        }
516    }
517
518    /// Whether the client should call [`crate::Client::login`] again before the next operation.
519    ///
520    /// True for session-key invalidation, expired credentials, and operator-state changes
521    /// detected mid-session.
522    #[must_use]
523    pub const fn requires_relogin(self) -> bool {
524        matches!(
525            self,
526            Self::SessionEncryptionError
527                | Self::BadOperatorPassword
528                | Self::NoSuchOperator
529                | Self::InactiveOperator
530                | Self::GenericLoginPrintError
531        )
532    }
533
534    /// Whether the underlying TCP connection should be closed and re-opened.
535    ///
536    /// Aligned with the "Stops the server connection" column of spec §4.10.
537    #[must_use]
538    pub const fn is_fatal_for_connection(self) -> bool {
539        // Exactly the codes marked "X" in the spec §4.10 "stops the server connection" column
540        // (verified against the original PDF). Note: the print errors 143/144/145 are NOT in this
541        // set — they are recoverable (e.g. `PrinterOutOfPaper` is retryable after a paper refill).
542        matches!(
543            self,
544            Self::InternalHdmError
545                | Self::BadRequest
546                | Self::BadProtocolVersion
547                | Self::UnauthorizedConnection
548                | Self::BadOperationCode
549                | Self::CryptographicError
550                | Self::HeaderFormatError
551                | Self::BadSequenceNumber
552                | Self::BadJsonFormat
553                | Self::BadOperatorPassword
554                | Self::NoSuchOperator
555                | Self::InactiveOperator
556                | Self::GenericLoginPrintError
557                | Self::HdmSyncRequired
558        )
559    }
560}
561
562#[cfg(test)]
563mod tests {
564    use super::*;
565
566    /// Every documented code round-trips through `from_code` and back to `code`.
567    #[test]
568    fn server_error_kind_round_trips_for_documented_codes() {
569        let documented_codes: &[u16] = &[
570            500, 400, 402, 403, 404, 101, 102, 103, 104, 105, 141, 142, 143, 144, 145, 111, 112,
571            113, 121, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165,
572            166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182,
573            183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196,
574        ];
575        for &code in documented_codes {
576            let kind = ServerErrorKind::from_code(code);
577            assert_ne!(
578                kind,
579                ServerErrorKind::Unknown(code),
580                "code {code} should map to a named variant"
581            );
582            assert_eq!(kind.code(), code, "round-trip for code {code}");
583        }
584    }
585
586    /// Undocumented codes survive as `Unknown(code)` without panicking.
587    #[test]
588    fn server_error_kind_preserves_unknown_codes() {
589        let kind = ServerErrorKind::from_code(999);
590        assert_eq!(kind, ServerErrorKind::Unknown(999));
591        assert_eq!(kind.code(), 999);
592    }
593
594    /// Vendor codes (not in spec §4.10) map to `Vendor(..)`, not `Unknown`, and round-trip.
595    #[test]
596    fn vendor_codes_map_to_vendor_variant() {
597        for (code, vendor) in [
598            (408, VendorErrorKind::ExternalProgramBlocked),
599            (503, VendorErrorKind::ServerDataMismatch),
600        ] {
601            assert_eq!(
602                ServerErrorKind::from_code(code),
603                ServerErrorKind::Vendor(vendor)
604            );
605            assert_eq!(ServerErrorKind::Vendor(vendor).code(), code);
606            assert_eq!(vendor.code(), code);
607            assert_eq!(VendorErrorKind::from_code(code), Some(vendor));
608        }
609        // A non-vendor unknown code stays Unknown.
610        assert_eq!(VendorErrorKind::from_code(999), None);
611    }
612
613    /// Login-related errors should hint at re-login.
614    #[test]
615    fn relogin_predicate_covers_session_errors() {
616        assert!(ServerErrorKind::SessionEncryptionError.requires_relogin());
617        assert!(ServerErrorKind::BadOperatorPassword.requires_relogin());
618        assert!(ServerErrorKind::NoSuchOperator.requires_relogin());
619        assert!(ServerErrorKind::InactiveOperator.requires_relogin());
620        // Business errors do NOT need re-login.
621        assert!(!ServerErrorKind::NoSuchDepartment.requires_relogin());
622        assert!(!ServerErrorKind::BadAtgCode.requires_relogin());
623    }
624
625    /// Transient hardware conditions are retryable; everything else is conservatively not.
626    #[test]
627    fn retry_predicate_is_narrow() {
628        assert!(ServerErrorKind::PrinterOutOfPaper.is_retryable());
629        assert!(ServerErrorKind::SyncIncomplete.is_retryable());
630        assert!(ServerErrorKind::Vendor(VendorErrorKind::ExternalProgramBlocked).is_retryable());
631        assert!(!ServerErrorKind::Vendor(VendorErrorKind::ServerDataMismatch).is_retryable());
632        assert!(!ServerErrorKind::BadAmounts.is_retryable());
633        assert!(!ServerErrorKind::Unknown(999).is_retryable());
634    }
635
636    /// `Error::Crypto` requires re-login by default — stale session keys are the dominant cause.
637    #[test]
638    fn crypto_error_demands_relogin() {
639        let err = Error::Crypto(CryptoError::BadPadding);
640        assert!(err.requires_relogin());
641        assert!(!err.requires_reconnect());
642        assert!(!err.is_retryable());
643    }
644
645    /// `is_fatal_for_connection` matches the spec §4.10 "stops connection" column exactly.
646    #[test]
647    fn fatal_for_connection_matches_spec_column() {
648        // Codes marked "X" in the spec.
649        for code in [
650            500, 400, 402, 403, 404, 101, 103, 104, 105, 111, 112, 113, 121, 155,
651        ] {
652            assert!(
653                ServerErrorKind::from_code(code).is_fatal_for_connection(),
654                "code {code} should be fatal per §4.10"
655            );
656        }
657        // Print errors 143/144/145 are recoverable, NOT fatal (regression guard).
658        for code in [102, 141, 142, 143, 144, 145, 151, 156] {
659            assert!(
660                !ServerErrorKind::from_code(code).is_fatal_for_connection(),
661                "code {code} should NOT be fatal per §4.10"
662            );
663        }
664        // Out-of-paper is recoverable: retryable and not a reconnect trigger.
665        assert!(ServerErrorKind::PrinterOutOfPaper.is_retryable());
666        assert!(!ServerErrorKind::PrinterOutOfPaper.is_fatal_for_connection());
667    }
668
669    /// A transport failure requires reconnect, not relogin or a bare retry of the same call.
670    #[test]
671    fn transport_error_requires_reconnect() {
672        let err = Error::Transport(std::io::Error::from(std::io::ErrorKind::ConnectionReset));
673        assert!(err.requires_reconnect());
674        assert!(!err.requires_relogin());
675        assert!(err.is_retryable());
676    }
677}