Skip to main content

hdm_am/
client.rs

1//! High-level HDM client. Owns the transport, the encryption phase (password key before login,
2//! session key after), and the sequence-number provider; exposes one method per spec operation.
3
4use crate::crypto::{Codec, decode_session_key};
5use crate::error::{Error, ServerErrorKind};
6use crate::operations::{
7    CashInOutRequest, DateTimeRequest, DateTimeResponse, EmptyResponse, FiscalReportRequest,
8    GetReturnableReceiptRequest, HdmTimeSyncRequest, ListOpsAndDepsRequest, ListOpsAndDepsResponse,
9    Operation, OperatorLoginRequest, OperatorLogoutRequest, PaymentSystemsListRequest,
10    PaymentSystemsListResponse, PrintLastReceiptRequest, PrintReceiptRequest,
11    PrintReturnReceiptRequest, ReceiptResponse, ReceiptSampleRequest, ReturnReceiptResponse,
12    ReturnableReceiptResponse, SetupHeaderFooterRequest, SetupHeaderLogoRequest,
13    SingleEmarkRequest,
14};
15use crate::seq::SequenceProvider;
16use crate::wire::{RESPONSE_CODE_OK, Request, ResponseHeader};
17use serde::Serialize;
18use std::io::{Read, Write};
19
20/// Wraps a session request with its per-call sequence number. The HDM expects `seq` as a top-level
21/// field next to the operation's own fields, so the request is flattened in. Keeping `seq` here
22/// instead of on the public request structs means callers never see (or have to set) it.
23#[derive(Serialize)]
24struct Sequenced<R> {
25    seq: i64,
26    #[serde(flatten)]
27    body: R,
28}
29
30/// Synchronous HDM client.
31///
32/// Generic over the transport (anything that's `Read + Write`) and the sequence-number provider
33/// (anything implementing [`SequenceProvider`]). The split allows in-memory testing via
34/// `std::io::Cursor`-style mocks and pluggable persistence (`InMemorySeq`, `FileSeq`, custom).
35///
36/// **Threading:** the client is not internally synchronised. Wrap in a `Mutex` if you need to
37/// share it across threads. The HDM itself is single-session anyway, so concurrent calls don't
38/// make sense at the protocol layer.
39///
40/// **Timeouts:** `Client` does not enforce timeouts on its transport. Configure
41/// `set_read_timeout`/`set_write_timeout` on a `TcpStream` before passing it to [`Self::new`].
42/// The spec's §4.2 step 7 mandates a 50-second cap on response wait time.
43pub struct Client<T: Read + Write, S: SequenceProvider> {
44    transport: T,
45    password_codec: Codec,
46    session_codec: Option<Codec>,
47    seq: S,
48    password: String,
49}
50
51impl<T: Read + Write, S: SequenceProvider> Client<T, S> {
52    /// Build a new client over `transport`, deriving the password key from `password`.
53    pub fn new(transport: T, password: impl Into<String>, seq: S) -> Self {
54        let password = password.into();
55        let password_codec = Codec::from_password(&password);
56        Self {
57            transport,
58            password_codec,
59            session_codec: None,
60            seq,
61            password,
62        }
63    }
64
65    /// Whether a session has been established via [`Self::login`] (and not invalidated).
66    #[must_use]
67    pub const fn is_logged_in(&self) -> bool {
68        self.session_codec.is_some()
69    }
70
71    /// Drop the in-memory session key. Does not notify the HDM — call [`Self::logout`] for that.
72    pub const fn forget_session(&mut self) {
73        self.session_codec = None;
74    }
75
76    // ---------------- Per-operation entry points ----------------
77
78    /// Op 1 (§4.5.1): list configured operators and departments. Does not require login.
79    ///
80    /// # Errors
81    /// See [`Error`].
82    pub fn list_operators_and_departments(&mut self) -> Result<ListOpsAndDepsResponse, Error> {
83        let request = ListOpsAndDepsRequest {
84            password: self.password.clone(),
85        };
86        self.execute_with_password(&request)
87    }
88
89    /// Op 2 (§4.5.2): operator login. On success the session key returned by the HDM is decoded
90    /// and installed; subsequent operations use it transparently.
91    ///
92    /// # Errors
93    /// - [`Error::Server`] with `kind = BadOperatorPassword / NoSuchOperator / InactiveOperator`
94    ///   on login failure.
95    /// - [`Error::Crypto`] (`CryptoError::SessionKeyBase64` / `InvalidKeyLength`) if the HDM
96    ///   returns a `key` field that isn't valid 24-byte Base64 (would indicate a device bug).
97    pub fn login(&mut self, cashier: u32, pin: impl Into<String>) -> Result<(), Error> {
98        let request = OperatorLoginRequest {
99            password: self.password.clone(),
100            cashier,
101            pin: pin.into(),
102        };
103        let response = self.execute_with_password(&request)?;
104        let session_key = decode_session_key(&response.key)?;
105        self.session_codec = Some(Codec::from_key(session_key));
106        log::info!("hdm-am: session established for cashier {cashier}");
107        Ok(())
108    }
109
110    /// Op 3 (§4.5.3): operator logout. Drops the session both server-side and locally.
111    ///
112    /// # Errors
113    /// See [`Error`]. Returns [`Error::NotLoggedIn`] if [`Self::login`] hasn't been called.
114    pub fn logout(&mut self) -> Result<(), Error> {
115        let _: EmptyResponse = self.execute_with_session(OperatorLogoutRequest {})?;
116        self.session_codec = None;
117        log::info!("hdm-am: session closed");
118        Ok(())
119    }
120
121    /// Op 4 (§4.5.4): print a fiscal receipt. The sequence number is assigned by the client.
122    ///
123    /// # Errors
124    /// See [`Error`]. Common business errors:
125    /// `NoSuchDepartment`, `BadAtgCode`, `PaidLessThanTotal`, `BadEmarkFormat`,
126    /// `PrinterOutOfPaper`, `HdmSyncRequired`. Check [`ServerErrorKind::is_retryable`] and
127    /// [`ServerErrorKind::requires_relogin`] on the returned error.
128    pub fn print_receipt(
129        &mut self,
130        request: PrintReceiptRequest,
131    ) -> Result<ReceiptResponse, Error> {
132        self.execute_with_session(request)
133    }
134
135    /// Op 5 (§4.5.5): reprint a copy of the operator's most recent receipt.
136    ///
137    /// # Errors
138    /// See [`Error`].
139    pub fn print_last_receipt(&mut self) -> Result<EmptyResponse, Error> {
140        self.execute_with_session(PrintLastReceiptRequest {})
141    }
142
143    /// Op 10 (§4.5.6): look up the contents of a receipt you intend to return.
144    ///
145    /// Read-only — returns the receipt's items, amounts and eMarks so you can build the actual
146    /// return ([`Self::print_return_receipt`], op 6). It registers nothing.
147    ///
148    /// # Errors
149    /// See [`Error`].
150    pub fn get_returnable_receipt(
151        &mut self,
152        receipt_id: impl Into<String>,
153        crn: impl Into<String>,
154    ) -> Result<ReturnableReceiptResponse, Error> {
155        self.execute_with_session(GetReturnableReceiptRequest {
156            receipt_id: receipt_id.into(),
157            crn: crn.into(),
158        })
159    }
160
161    /// Op 7 (§4.6.3): configure the header/footer lines printed on every receipt.
162    ///
163    /// # Errors
164    /// See [`Error`].
165    pub fn setup_header_footer(
166        &mut self,
167        request: SetupHeaderFooterRequest,
168    ) -> Result<EmptyResponse, Error> {
169        self.execute_with_session(request)
170    }
171
172    /// Op 8 (§4.6.4): upload a header logo image (Base64-encoded BMP, colour depth ≤4 bits).
173    ///
174    /// # Errors
175    /// See [`Error`].
176    pub fn setup_header_logo(
177        &mut self,
178        logo_base64: impl Into<String>,
179    ) -> Result<EmptyResponse, Error> {
180        self.execute_with_session(SetupHeaderLogoRequest {
181            header_logo: logo_base64.into(),
182        })
183    }
184
185    /// Op 9 (§4.6.2): print a fiscal report (X-report = interim, Z-report = end-of-day).
186    ///
187    /// # Errors
188    /// See [`Error`].
189    pub fn fiscal_report(&mut self, request: FiscalReportRequest) -> Result<EmptyResponse, Error> {
190        self.execute_with_session(request)
191    }
192
193    /// Op 6 (§4.5.7): print a return/refund receipt — the operation that actually registers a
194    /// return. Full, by-amount or per-item returns are driven via the request's optional fields.
195    /// The read-only lookup of the receipt being returned is op 10 ([`Self::get_returnable_receipt`]).
196    ///
197    /// # Errors
198    /// See [`Error`].
199    pub fn print_return_receipt(
200        &mut self,
201        request: PrintReturnReceiptRequest,
202    ) -> Result<ReturnReceiptResponse, Error> {
203        self.execute_with_session(request)
204    }
205
206    /// Op 11 (§4.5.8): record a cash-drawer in/out adjustment.
207    ///
208    /// # Errors
209    /// See [`Error`].
210    pub fn cash_in_out(&mut self, request: CashInOutRequest) -> Result<EmptyResponse, Error> {
211        self.execute_with_session(request)
212    }
213
214    /// Op 12 (§4.6): query the HDM's current date and time.
215    ///
216    /// # Errors
217    /// See [`Error`].
218    pub fn date_time(&mut self) -> Result<DateTimeResponse, Error> {
219        self.execute_with_session(DateTimeRequest {})
220    }
221
222    /// Op 13 (§4.6.1): print a sample receipt for layout/operator verification.
223    ///
224    /// # Errors
225    /// See [`Error`].
226    pub fn receipt_sample(&mut self) -> Result<EmptyResponse, Error> {
227        self.execute_with_session(ReceiptSampleRequest {})
228    }
229
230    /// Op 14 (§4.7): synchronise the HDM with the tax authority's clock/state.
231    ///
232    /// # Errors
233    /// See [`Error`].
234    pub fn hdm_time_sync(&mut self) -> Result<EmptyResponse, Error> {
235        self.execute_with_session(HdmTimeSyncRequest {})
236    }
237
238    /// Op 15 (§4.8): list the payment systems configured on the HDM. Use this once at startup
239    /// to discover the code-to-name mapping for [`PrintReceiptRequest::payment_system`] rather
240    /// than hardcoding codes.
241    ///
242    /// # Errors
243    /// See [`Error`].
244    pub fn payment_systems_list(&mut self) -> Result<PaymentSystemsListResponse, Error> {
245        self.execute_with_session(PaymentSystemsListRequest {})
246    }
247
248    /// Op 16 (§4.9): submit a single eMark traceability code.
249    ///
250    /// # Errors
251    /// See [`Error`]. `BadEmarkFormat` for malformed codes (see §4.9 escaping rules).
252    pub fn single_emark(&mut self, e_mark: impl Into<String>) -> Result<EmptyResponse, Error> {
253        self.execute_with_session(SingleEmarkRequest {
254            e_mark: e_mark.into(),
255        })
256    }
257
258    // ---------------- Internals ----------------
259
260    /// Execute an operation that uses the password-derived key (ops 1 and 2).
261    fn execute_with_password<R: Operation>(&mut self, request: &R) -> Result<R::Response, Error> {
262        const { assert!(R::USES_PASSWORD_KEY, "expected password-key op") };
263        let codec = self.password_codec.clone();
264        self.round_trip_op::<R>(request, &codec)
265    }
266
267    /// Execute an operation that uses the session key (all ops except 1 and 2).
268    ///
269    /// If the operation fails with a relogin-class error (stale key, server-side session timeout,
270    /// or a code that mandates re-login), the local session is dropped so [`Self::is_logged_in`]
271    /// reflects reality and the next call fails fast as [`Error::NotLoggedIn`].
272    fn execute_with_session<R: Operation>(&mut self, request: R) -> Result<R::Response, Error> {
273        const { assert!(!R::USES_PASSWORD_KEY, "expected session-key op") };
274        let codec = self
275            .session_codec
276            .as_ref()
277            .ok_or(Error::NotLoggedIn)?
278            .clone();
279        let seq = self.next_seq()?;
280        let body = Sequenced { seq, body: request };
281        let result = self.round_trip_op::<R>(&body, &codec);
282        if let Err(ref err) = result {
283            if err.requires_relogin() {
284                self.session_codec = None;
285                log::debug!("hdm-am: session invalidated after a relogin-class error");
286            }
287        }
288        result
289    }
290
291    /// Perform a full request/response round-trip: JSON-encode → encrypt → write framing →
292    /// read header → check server code → read payload → decrypt → JSON-decode.
293    fn round_trip_op<R: Operation>(
294        &mut self,
295        body: &impl Serialize,
296        codec: &Codec,
297    ) -> Result<R::Response, Error> {
298        let plaintext = serde_json::to_vec(body).map_err(Error::Encode)?;
299        let ciphertext = codec.encrypt(&plaintext)?;
300        let payload_len = ciphertext.len();
301        u16::try_from(payload_len).map_err(|_| Error::PayloadTooLarge { len: payload_len })?;
302
303        log::debug!(
304            "hdm-am: -> op {:?} ({} plaintext / {} ciphertext bytes)",
305            R::CODE,
306            plaintext.len(),
307            ciphertext.len()
308        );
309
310        let wire = Request {
311            op: R::CODE,
312            payload: ciphertext,
313        };
314        wire.encode(&mut self.transport)?;
315
316        let header = ResponseHeader::read(&mut self.transport)?;
317
318        log::debug!(
319            "hdm-am: <- op {:?} response code {} ({} bytes payload)",
320            R::CODE,
321            header.code,
322            header.payload_len
323        );
324
325        // Always drain the payload bytes from the transport, even on error, so that the
326        // connection stays in sync for the next request.
327        let mut payload = vec![0u8; usize::from(header.payload_len)];
328        if header.payload_len > 0 {
329            self.transport.read_exact(&mut payload)?;
330        }
331
332        if header.code != RESPONSE_CODE_OK {
333            let kind = ServerErrorKind::from_code(header.code);
334            log::warn!(
335                "hdm-am: op {:?} returned server code {} ({:?})",
336                R::CODE,
337                header.code,
338                kind
339            );
340            return Err(Error::Server {
341                code: header.code,
342                kind,
343            });
344        }
345
346        // Empty success payload — most "no-content" ops (logout, sample, etc.) come through here.
347        if payload.is_empty() {
348            return serde_json::from_slice(b"{}").map_err(Error::Decode);
349        }
350
351        let plaintext_response = codec.decrypt(&payload)?;
352        if R::RESPONSE_IS_SECRET {
353            // e.g. the login response carries the session key — never log it, even at TRACE.
354            log::trace!(
355                "hdm-am: <- op {:?} decrypted payload: [redacted: response carries a secret]",
356                R::CODE
357            );
358        } else {
359            log::trace!(
360                "hdm-am: <- op {:?} decrypted payload: {}",
361                R::CODE,
362                String::from_utf8_lossy(&plaintext_response)
363            );
364        }
365        serde_json::from_slice(&plaintext_response).map_err(Error::Decode)
366    }
367
368    fn next_seq(&mut self) -> Result<i64, Error> {
369        self.seq.next().map_err(Error::Transport)
370    }
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376    use crate::error::CryptoError;
377    use crate::seq::InMemorySeq;
378    use crate::wire::{MAGIC, OperationCode, PROTOCOL_VERSION};
379    use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
380    use rust_decimal::Decimal;
381    use std::io::{self, Cursor};
382
383    /// Loopback transport: write side captures bytes for assertions, read side is pre-loaded
384    /// with the bytes we want the server to "send".
385    struct Loopback {
386        written: Vec<u8>,
387        incoming: Cursor<Vec<u8>>,
388    }
389
390    impl Loopback {
391        fn new(incoming: Vec<u8>) -> Self {
392            Self {
393                written: Vec::new(),
394                incoming: Cursor::new(incoming),
395            }
396        }
397
398        fn no_incoming() -> Self {
399            Self::new(Vec::new())
400        }
401    }
402
403    impl Read for Loopback {
404        fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
405            self.incoming.read(buf)
406        }
407    }
408
409    impl Write for Loopback {
410        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
411            self.written.extend_from_slice(buf);
412            Ok(buf.len())
413        }
414        fn flush(&mut self) -> io::Result<()> {
415            Ok(())
416        }
417    }
418
419    /// Build a fake server response envelope (header + ciphertext) using the given codec.
420    fn make_response(code: u16, codec: &Codec, plaintext: &[u8]) -> Vec<u8> {
421        let ciphertext = codec.encrypt(plaintext).expect("encrypt");
422        let len = u16::try_from(ciphertext.len()).expect("fits in u16");
423        let mut out = Vec::new();
424        // pv (2) + sw (3) + code (2) + len (2) + reserved (2) = 11 bytes
425        out.extend_from_slice(&[0x00, 0x05]); // protocol version
426        out.extend_from_slice(&[0x02, 0x02, 0x10]); // sw version
427        out.extend_from_slice(&code.to_be_bytes()); // code
428        out.extend_from_slice(&len.to_be_bytes()); // payload length
429        out.extend_from_slice(&[0x00, 0x00]); // reserved
430        out.extend_from_slice(&ciphertext);
431        out
432    }
433
434    /// `is_logged_in` reflects the session state.
435    #[test]
436    fn is_logged_in_false_until_login() {
437        let client = Client::new(Loopback::no_incoming(), "pw", InMemorySeq::default());
438        assert!(!client.is_logged_in());
439    }
440
441    /// Calling a session-requiring op before login returns `Error::NotLoggedIn`.
442    #[test]
443    fn session_ops_require_login() {
444        let mut client = Client::new(Loopback::no_incoming(), "pw", InMemorySeq::default());
445        let err = client.logout().expect_err("expected NotLoggedIn");
446        assert!(matches!(err, Error::NotLoggedIn));
447        assert!(err.requires_relogin());
448    }
449
450    /// Successful login installs the session codec, returning Ok(()).
451    #[test]
452    fn login_happy_path_installs_session() {
453        // Prepare a fake server response: HTTP 200 with a Base64-encoded 24-byte session key.
454        let password = "test-password";
455        let password_codec = Codec::from_password(password);
456        let session_key = [0xAB_u8; 24];
457        let key_b64 = BASE64.encode(session_key);
458        let response_json = format!(r#"{{"key":"{key_b64}"}}"#);
459        let incoming = make_response(200, &password_codec, response_json.as_bytes());
460
461        let mut client = Client::new(Loopback::new(incoming), password, InMemorySeq::default());
462        client.login(42, "1234").expect("login should succeed");
463        assert!(client.is_logged_in());
464    }
465
466    /// Wire bytes written during login start with the correct framing (magic + version + op).
467    #[test]
468    fn login_writes_correct_wire_framing() {
469        let password = "pw";
470        let password_codec = Codec::from_password(password);
471        let key_b64 = BASE64.encode([0u8; 24]);
472        let response_json = format!(r#"{{"key":"{key_b64}"}}"#);
473        let incoming = make_response(200, &password_codec, response_json.as_bytes());
474
475        let mut client = Client::new(Loopback::new(incoming), password, InMemorySeq::default());
476        client.login(1, "0000").expect("login");
477        let written = &client.transport.written;
478        assert!(written.starts_with(&MAGIC));
479        assert_eq!(&written[6..8], PROTOCOL_VERSION);
480        assert_eq!(written[8], OperationCode::OperatorLogin as u8);
481        assert_eq!(written[9], 0); // reserved
482    }
483
484    /// A bad-password response (code 111) surfaces as `Error::Server { kind: BadOperatorPassword }`.
485    #[test]
486    fn login_surfaces_bad_password_error() {
487        let password = "wrong";
488        let password_codec = Codec::from_password(password);
489        // For a non-200 response, body content doesn't matter — but we still encrypt
490        // an empty `{}` to keep framing valid.
491        let incoming = make_response(111, &password_codec, b"{}");
492
493        let mut client = Client::new(Loopback::new(incoming), password, InMemorySeq::default());
494        let err = client.login(1, "0000").expect_err("expected login failure");
495        match err {
496            Error::Server { code, kind } => {
497                assert_eq!(code, 111);
498                assert_eq!(kind, ServerErrorKind::BadOperatorPassword);
499                assert!(kind.requires_relogin());
500            }
501            other => panic!("unexpected variant: {other:?}"),
502        }
503        // Failed login must not install a session.
504        assert!(!client.is_logged_in());
505    }
506
507    /// `Error::Crypto(BadPadding)` is the typical symptom of a stale session key.
508    #[test]
509    fn decryption_failure_after_login_surfaces_as_crypto_error() {
510        let password = "pw";
511        let password_codec = Codec::from_password(password);
512
513        // Stage 1: install session — successful login.
514        let session_key = [0x11_u8; 24];
515        let key_b64 = BASE64.encode(session_key);
516        let login_response = format!(r#"{{"key":"{key_b64}"}}"#);
517        let mut incoming = make_response(200, &password_codec, login_response.as_bytes());
518
519        // Stage 2: an op-12 response, but encrypted with the WRONG key. Decryption will fail.
520        let wrong_codec = Codec::from_key([0x99; 24]);
521        incoming.extend_from_slice(&make_response(200, &wrong_codec, br#"{"dt":"now"}"#));
522
523        let mut client = Client::new(Loopback::new(incoming), password, InMemorySeq::default());
524        client.login(1, "0000").expect("login");
525
526        let err = client.date_time().expect_err("expected decryption failure");
527        match err {
528            Error::Crypto(CryptoError::BadPadding) => {}
529            other => panic!("expected Crypto(BadPadding), got {other:?}"),
530        }
531        assert!(err.requires_relogin());
532    }
533
534    /// The client injects the sequence number into the request it sends; callers never set it.
535    #[test]
536    fn print_receipt_injects_sequence_number() {
537        use crate::operations::PrintMode;
538
539        let password = "pw";
540        let password_codec = Codec::from_password(password);
541        let key_b64 = BASE64.encode([0; 24]);
542        let login_resp = format!(r#"{{"key":"{key_b64}"}}"#);
543        let session_codec = Codec::from_key([0; 24]);
544
545        let mut wire = make_response(200, &password_codec, login_resp.as_bytes());
546        // Receipt response — minimal valid ReceiptResponse.
547        let receipt_json = r#"{"rseq":1,"crn":"","sn":"","tin":"","taxpayer":"","address":"","time":0,"fiscal":"","lottery":"","prize":0,"total":0.0,"change":0.0}"#;
548        wire.extend_from_slice(&make_response(200, &session_codec, receipt_json.as_bytes()));
549
550        let seq = InMemorySeq::starting_at(99);
551        let mut client = Client::new(Loopback::new(wire), password, seq);
552        client.login(1, "0").unwrap();
553
554        let request = PrintReceiptRequest {
555            mode: PrintMode::Simple,
556            paid_amount: Decimal::from(100),
557            paid_amount_card: Decimal::ZERO,
558            partial_amount: Decimal::ZERO,
559            pre_payment_amount: Decimal::ZERO,
560            dep: Some(1),
561            partner_tin: None,
562            use_ext_pos: false,
563            payment_system: None,
564            rrn: None,
565            terminal_id: None,
566            e_marks: vec![],
567            items: vec![],
568        };
569        let response = client.print_receipt(request).expect("print receipt");
570        assert_eq!(response.rseq, 1);
571
572        // Decrypt the last request the client wrote and confirm it injected the sequence number
573        // (login leaves the counter at 99; the first session op takes 100) alongside the flattened
574        // request body.
575        let written = &client.transport.written;
576        let mut off = 0;
577        let mut last_payload: &[u8] = &[];
578        while off + 12 <= written.len() {
579            let len = usize::from(u16::from_be_bytes([written[off + 10], written[off + 11]]));
580            last_payload = &written[off + 12..off + 12 + len];
581            off += 12 + len;
582        }
583        let decrypted = session_codec
584            .decrypt(last_payload)
585            .expect("decrypt request");
586        let json: serde_json::Value = serde_json::from_slice(&decrypted).expect("valid JSON");
587        assert_eq!(json["seq"], 100, "client must inject the sequence number");
588        assert_eq!(json["mode"], 1, "request body is flattened alongside seq");
589    }
590
591    /// Server-side errors include the raw payload drain — connection stays in sync for the next op.
592    #[test]
593    fn error_response_drains_payload_to_keep_transport_in_sync() {
594        let password = "pw";
595        let password_codec = Codec::from_password(password);
596
597        // Two consecutive responses on the same transport: first is an error with non-zero
598        // payload, second is a valid one. If we don't drain the error's payload, the second
599        // read will be misaligned.
600        let mut incoming = make_response(151, &password_codec, b"some-irrelevant-error-body");
601        let key_b64 = BASE64.encode([0u8; 24]);
602        let login_response = format!(r#"{{"key":"{key_b64}"}}"#);
603        incoming.extend_from_slice(&make_response(
604            200,
605            &password_codec,
606            login_response.as_bytes(),
607        ));
608
609        let mut client = Client::new(Loopback::new(incoming), password, InMemorySeq::default());
610
611        // First call: server returns 151 (NoSuchDepartment).
612        let err = client
613            .list_operators_and_departments()
614            .expect_err("expected error");
615        assert!(matches!(
616            err,
617            Error::Server {
618                kind: ServerErrorKind::NoSuchDepartment,
619                ..
620            }
621        ));
622
623        // Second call must succeed — proves payload from the first response was drained.
624        client.login(1, "0000").expect("second call should align");
625        assert!(client.is_logged_in());
626    }
627}