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` — eMark code belongs to another country ("Այլ երկրի ծածկագիր").
293 ForeignCountryEmark,
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::ForeignCountryEmark,
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::ForeignCountryEmark => 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}