Skip to main content

mako_engine/
partner.rs

1//! Trading-partner master data — the [`PartnerStore`] trait and supporting
2//! types.
3//!
4//! # Why not just a `HashMap<GLN, URL>` in config?
5//!
6//! The `partners = ["GLN=URL", …]` field in `makod.toml` works for
7//! development but falls short in production:
8//!
9//! | Requirement | Config-only | `PartnerStore` |
10//! |---|---|---|
11//! | Survives restarts without re-deployment | ❌ | ✅ |
12//! | Carries PARTIN-derived metadata (validity, contacts, bank) | ❌ | ✅ |
13//! | Updatable from inbound PARTIN messages at runtime | ❌ | ✅ |
14//! | Tenant-scoped isolation | ❌ | ✅ |
15//! | Multiple communication channels per partner | ❌ | ✅ |
16//! | Validity windows (Gültig Ab) for future-dated updates | ❌ | ✅ |
17//!
18//! # PARTIN data model
19//!
20//! The German energy market uses EDIFACT **PARTIN** messages (PIDs 37000–37014)
21//! to distribute market-participant master data. Each PARTIN carries:
22//!
23//! - `NAD` → GLN, company name, country code
24//! - `COM` → communication channels: AS4 endpoint URL, email, fax (up to 5)
25//! - `CCI/CAV` → availability windows (*Erreichbarkeit*)
26//! - `FII` → bank account (IBAN, BIC)
27//! - `RFF` → tax number, VAT ID
28//! - `CTA/NAD` → contact persons (*Ansprechpartner*)
29//! - `DTM` → valid-from date (*Gültig Ab*)
30//! - `CCI` → associated Bilanzkreis
31//!
32//! [`PartnerRecord`] captures all of these fields in a form that is both
33//! serializable to SlateDB and constructible from static config.
34//!
35//! # Bootstrap pattern
36//!
37//! ```rust,ignore
38//! // At startup — seed from makod.toml `[as4] partners` list:
39//! for record in PartnerRecord::from_cli_pairs(&config.as4.partners)? {
40//!     store.upsert(tenant_id, &record).await?;
41//! }
42//!
43//! // Later — update from inbound PARTIN message:
44//! let record = parse_partin_37001(&edifact_interchange)?;
45//! store.upsert(tenant_id, &record).await?;
46//!
47//! // Outbound AS4 dispatch:
48//! let partner = store.get(tenant_id, &gln).await?
49//!     .ok_or(EngineError::partner(format!("no endpoint for {mp_id}")))?;
50//! let endpoint = partner.as4_endpoint
51//!     .ok_or(EngineError::partner(format!("{mp_id} has no AS4 endpoint")))?;
52//! ```
53//!
54//! # Key schema (SlateDB)
55//!
56//! `pt/{tenant_id}/{mp_id}` → `JSON(PartnerRecord)`
57//!
58//! Both `TenantId` and GLN are fixed-width strings, giving a
59//! `pt/{36-chars}/{13-chars}` prefix that bounds efficient per-tenant scans.
60
61use std::sync::Arc;
62
63#[cfg(any(test, feature = "testing"))]
64use std::collections::HashMap;
65#[cfg(any(test, feature = "testing"))]
66use tokio::sync::RwLock;
67
68use serde::{Deserialize, Serialize};
69use time::OffsetDateTime;
70
71use crate::{error::EngineError, ids::TenantId, marktrolle::Marktrolle, types::MarktpartnerCode};
72
73// ── CommunicationChannel ──────────────────────────────────────────────────────
74
75/// A single communication channel extracted from a PARTIN `COM` segment.
76///
77/// PARTIN allows up to 5 `COM` segments per party. The `qualifier` uses the
78/// UN/EDIFACT DE 3155 code list:
79///
80/// | Qualifier | Meaning |
81/// |---|---|
82/// | `EM` | Electronic mail (primary) |
83/// | `AK` | Electronic mail (alternative) |
84/// | `TE` | Telephone |
85/// | `FX` | Fax |
86/// | `AS4` | BDEW AS4 endpoint URL (non-standard extension) |
87/// | `AW` | BDEW API-Webdienste Strom endpoint URL (Verzeichnisdienst-discovered) |
88///
89/// > **Note**: BDEW uses qualifier `AK` for the AS4 endpoint URL in PARTIN
90/// > AHB 1.0f. The `AS4` literal is used here as an explicit semantic label
91/// > for channels that have already been identified as AS4 endpoints.
92/// >
93/// > `AW` is a project-internal qualifier used to store the API-Webdienste
94/// > Strom base URL discovered from the BDEW Verzeichnisdienst.
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96pub struct CommunicationChannel {
97    /// DE 3155 communication qualifier (`EM`, `TE`, `FX`, `AK`, …).
98    pub qualifier: Box<str>,
99    /// The communication address (URL, email address, phone number).
100    pub address: Box<str>,
101}
102
103impl CommunicationChannel {
104    /// Construct a new channel.
105    #[must_use]
106    pub fn new(qualifier: impl Into<Box<str>>, address: impl Into<Box<str>>) -> Self {
107        Self {
108            qualifier: qualifier.into(),
109            address: address.into(),
110        }
111    }
112
113    /// Convenience: construct an AS4 endpoint channel.
114    ///
115    /// Uses qualifier `"AK"` per PARTIN AHB 1.0f DE 3155 convention.
116    #[must_use]
117    pub fn as4(endpoint_url: impl Into<Box<str>>) -> Self {
118        Self::new("AK", endpoint_url)
119    }
120
121    /// Convenience: construct an email channel.
122    #[must_use]
123    pub fn email(address: impl Into<Box<str>>) -> Self {
124        Self::new("EM", address)
125    }
126
127    /// Convenience: construct an API-Webdienste Strom endpoint channel.
128    ///
129    /// Uses qualifier `"AW"` (project-internal) to store the base URL
130    /// discovered from the BDEW Verzeichnisdienst for a given partner.
131    #[must_use]
132    pub fn api_webdienste(base_url: impl Into<Box<str>>) -> Self {
133        Self::new("AW", base_url)
134    }
135}
136
137// ── ContactPerson ─────────────────────────────────────────────────────────────
138
139/// A contact person extracted from a PARTIN `CTA`/`NAD`/`COM` group.
140///
141/// Corresponds to the *Ansprechpartner* group in PARTIN AHB 1.0f.
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143pub struct ContactPerson {
144    /// Full name or department name.
145    pub name: Box<str>,
146    /// Contact channels (phone, email, …).
147    pub channels: Vec<CommunicationChannel>,
148}
149
150// ── PartnerRecord ─────────────────────────────────────────────────────────────
151
152/// Full trading-partner master record as stored in the [`PartnerStore`].
153///
154/// Populated either from static `makod.toml` config (minimal — GLN + AS4 URL
155/// only) or from an inbound PARTIN EDIFACT message (complete). Records from
156/// different sources coexist: a bootstrapped config record is upgraded in-place
157/// when the same partner later sends a PARTIN.
158///
159/// ## Constructors
160///
161/// - [`PartnerRecord::minimal`] — for bootstrapping from `GLN=URL` config pairs
162/// - [`PartnerRecord::from_cli_pairs`] — parse `[as4] partners` list from config
163///
164/// ## Merging
165///
166/// Use [`PartnerRecord::merge_from_partin`] to update an existing record with
167/// fields from a newer inbound PARTIN (respects validity dates).
168#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
169pub struct PartnerRecord {
170    /// The partner's 13-digit Global Location Number.
171    pub mp_id: MarktpartnerCode,
172
173    /// Company name from the PARTIN `NAD` segment.
174    pub display_name: Option<Box<str>>,
175
176    /// All communication channels from PARTIN `COM` segments.
177    ///
178    /// The AS4 endpoint is the entry with qualifier `"AK"` (PARTIN AHB 1.0f
179    /// DE 3155 convention).  Use [`as4_endpoint`] for direct access.
180    ///
181    /// [`as4_endpoint`]: PartnerRecord::as4_endpoint
182    pub channels: Vec<CommunicationChannel>,
183
184    /// Market roles this partner has declared via PARTIN.
185    ///
186    /// Derived from the PARTIN Prüfidentifikator via
187    /// [`Marktrolle::from_partin_pid`]. Serialises as BDEW role codes
188    /// (`"LF"`, `"NB"`, `"MSB"`, …).
189    pub roles: Vec<Marktrolle>,
190
191    /// Date from which this record version is valid (`DTM/137`).
192    ///
193    /// `None` when bootstrapped from static config (no validity date known).
194    #[serde(
195        default,
196        skip_serializing_if = "Option::is_none",
197        with = "time::serde::rfc3339::option"
198    )]
199    pub valid_from: Option<OffsetDateTime>,
200
201    /// Contact persons from the PARTIN *Ansprechpartner* group.
202    pub contacts: Vec<ContactPerson>,
203
204    /// ISO 3166-1 alpha-2 country code from `NAD+MS+++...+DE` (usually `DE`).
205    pub country_code: Option<Box<str>>,
206
207    /// Wall-clock time when this record was last written to the store.
208    #[serde(with = "time::serde::rfc3339")]
209    pub updated_at: OffsetDateTime,
210}
211
212impl PartnerRecord {
213    /// Create a minimal record from a GLN and an AS4 endpoint URL.
214    ///
215    /// Used when bootstrapping from `[as4] partners = ["GLN=URL", …]` in
216    /// `makod.toml`. The record has no PARTIN-derived metadata — only the
217    /// GLN and a single AS4 channel.
218    #[must_use]
219    pub fn minimal(mp_id: impl Into<MarktpartnerCode>, as4_url: impl Into<Box<str>>) -> Self {
220        Self {
221            mp_id: mp_id.into(),
222            display_name: None,
223            channels: vec![CommunicationChannel::as4(as4_url)],
224            roles: Vec::new(),
225            valid_from: None,
226            contacts: Vec::new(),
227            country_code: None,
228            updated_at: OffsetDateTime::now_utc(),
229        }
230    }
231
232    /// Parse `["GLN=HTTPS-URL", …]` configuration entries into minimal records.
233    ///
234    /// Returns an error on the first malformed or non-HTTPS entry.
235    ///
236    /// # Errors
237    ///
238    /// Returns [`EngineError::Partner`] when an entry lacks `=`, has an empty
239    /// GLN, or uses a non-HTTPS URL.
240    pub fn from_cli_pairs(pairs: &[impl AsRef<str>]) -> Result<Vec<Self>, EngineError> {
241        pairs
242            .iter()
243            .map(|entry| {
244                let pair = entry.as_ref();
245                let (mp_id, url) = pair.split_once('=').ok_or_else(|| {
246                    EngineError::partner(format!(
247                        "invalid partner entry {pair:?} — expected <GLN>=<HTTPS-URL>"
248                    ))
249                })?;
250                let mp_id = mp_id.trim();
251                let url = url.trim();
252                if mp_id.is_empty() {
253                    return Err(EngineError::partner(format!(
254                        "invalid partner entry {pair:?} — GLN must not be empty"
255                    )));
256                }
257                if !url.starts_with("https://") {
258                    return Err(EngineError::partner(format!(
259                        "invalid partner entry {pair:?} — endpoint URL must use HTTPS (got {url:?})"
260                    )));
261                }
262                Ok(Self::minimal(mp_id, url))
263            })
264            .collect()
265    }
266
267    /// Return the AS4 endpoint URL if one has been registered.
268    ///
269    /// Looks for a channel with qualifier `"AK"` (PARTIN AHB 1.0f
270    /// convention for the AS4 endpoint). Falls back to `"AS4"` for records
271    /// that were imported with a non-standard qualifier.
272    #[must_use]
273    pub fn as4_endpoint(&self) -> Option<&str> {
274        self.channels
275            .iter()
276            .find(|c| c.qualifier.as_ref() == "AK" || c.qualifier.as_ref() == "AS4")
277            .map(|c| c.address.as_ref())
278    }
279
280    /// Return the primary email address if one has been registered.
281    ///
282    /// Looks for a channel with qualifier `"EM"`.
283    #[must_use]
284    pub fn email(&self) -> Option<&str> {
285        self.channels
286            .iter()
287            .find(|c| c.qualifier.as_ref() == "EM")
288            .map(|c| c.address.as_ref())
289    }
290
291    /// Return the API-Webdienste Strom base URL if one has been registered.
292    ///
293    /// Looks for a channel with qualifier `"AW"`.  This URL is typically
294    /// populated by the Verzeichnisdienst discovery worker and is
295    /// used by `MaloIdentSender` to reach the LF's callback endpoint.
296    #[must_use]
297    pub fn api_webdienste_endpoint(&self) -> Option<&str> {
298        self.channels
299            .iter()
300            .find(|c| c.qualifier.as_ref() == "AW")
301            .map(|c| c.address.as_ref())
302    }
303
304    /// Merge fields from a newer PARTIN-derived record into `self`.
305    ///
306    /// Only updates `self` when `incoming.valid_from` is newer than
307    /// `self.valid_from` (or when `self.valid_from` is `None`). Config-
308    /// bootstrapped records (no `valid_from`) are always overwritten.
309    ///
310    /// The GLN must match — mismatches are silently ignored (the caller is
311    /// responsible for routing PARTIN messages to the correct record).
312    pub fn merge_from_partin(&mut self, incoming: PartnerRecord) {
313        if incoming.mp_id != self.mp_id {
314            return;
315        }
316        let should_update = match (self.valid_from, incoming.valid_from) {
317            (None, _) => true,
318            (Some(_), None) => false, // keep the dated record
319            (Some(a), Some(b)) => b >= a,
320        };
321        if !should_update {
322            return;
323        }
324        self.display_name = incoming.display_name.or(self.display_name.take());
325        self.channels = incoming.channels;
326        self.roles = incoming.roles;
327        self.valid_from = incoming.valid_from;
328        self.contacts = incoming.contacts;
329        self.country_code = incoming.country_code.or(self.country_code.take());
330        self.updated_at = incoming.updated_at;
331    }
332}
333
334// ── PartnerStore ──────────────────────────────────────────────────────────────
335
336/// Durable store for trading-partner master records.
337///
338/// Provides tenant-scoped access to [`PartnerRecord`]s. Records are upserted
339/// when a new PARTIN message arrives or when `makod` bootstraps from static
340/// config.
341///
342/// All three operations are idempotent — reinserting the same record is safe.
343///
344/// ## Blanket `Arc` implementation
345///
346/// `Arc<S>` implements `PartnerStore` whenever `S: PartnerStore`.
347#[allow(async_fn_in_trait)]
348pub trait PartnerStore: Send + Sync {
349    /// Insert or update the record for `(tenant_id, record.mp_id)`.
350    ///
351    /// If a record already exists for this GLN, it is **merged** via
352    /// [`PartnerRecord::merge_from_partin`] — i.e. the newer PARTIN-derived
353    /// record wins, but a config-only bootstrap is always overwritten.
354    ///
355    /// # Errors
356    ///
357    /// Returns [`EngineError::Partner`] on storage failure.
358    async fn upsert(&self, tenant_id: TenantId, record: &PartnerRecord) -> Result<(), EngineError>;
359
360    /// Return the record for `(tenant_id, gln)`, or `None` if not registered.
361    ///
362    /// # Errors
363    ///
364    /// Returns [`EngineError::Partner`] on storage failure.
365    async fn get(
366        &self,
367        tenant_id: TenantId,
368        mp_id: &MarktpartnerCode,
369    ) -> Result<Option<PartnerRecord>, EngineError>;
370
371    /// Remove the record for `(tenant_id, gln)`.
372    ///
373    /// No-op when the record does not exist.
374    ///
375    /// # Errors
376    ///
377    /// Returns [`EngineError::Partner`] on storage failure.
378    async fn remove(
379        &self,
380        tenant_id: TenantId,
381        mp_id: &MarktpartnerCode,
382    ) -> Result<(), EngineError>;
383
384    /// Return all records registered for `tenant_id`.
385    ///
386    /// # Errors
387    ///
388    /// Returns [`EngineError::Partner`] on storage failure.
389    async fn list(&self, tenant_id: TenantId) -> Result<Vec<PartnerRecord>, EngineError>;
390
391    /// Return the AS4 endpoint URL for `gln`, if known.
392    ///
393    /// Convenience wrapper over `get` + `as4_endpoint`.
394    ///
395    /// # Errors
396    ///
397    /// Returns [`EngineError::Partner`] on storage failure.
398    async fn as4_endpoint(
399        &self,
400        tenant_id: TenantId,
401        mp_id: &MarktpartnerCode,
402    ) -> Result<Option<Box<str>>, EngineError> {
403        Ok(self
404            .get(tenant_id, mp_id)
405            .await?
406            .and_then(|r| r.as4_endpoint().map(std::convert::Into::into)))
407    }
408
409    /// Return the API-Webdienste Strom base URL for `gln`, if known.
410    ///
411    /// Looks for a channel with qualifier `"AW"` (populated by the
412    /// Verzeichnisdienst discovery path.
413    ///
414    /// Convenience wrapper over `get` + `api_webdienste_endpoint`.
415    ///
416    /// # Errors
417    ///
418    /// Returns [`EngineError::Partner`] on storage failure.
419    async fn api_webdienste_endpoint(
420        &self,
421        tenant_id: TenantId,
422        mp_id: &MarktpartnerCode,
423    ) -> Result<Option<Box<str>>, EngineError> {
424        Ok(self
425            .get(tenant_id, mp_id)
426            .await?
427            .and_then(|r| r.api_webdienste_endpoint().map(std::convert::Into::into)))
428    }
429}
430
431// ── Arc<S> blanket impl ───────────────────────────────────────────────────────
432
433impl<S: PartnerStore> PartnerStore for Arc<S> {
434    async fn upsert(&self, tenant_id: TenantId, record: &PartnerRecord) -> Result<(), EngineError> {
435        self.as_ref().upsert(tenant_id, record).await
436    }
437
438    async fn get(
439        &self,
440        tenant_id: TenantId,
441        mp_id: &MarktpartnerCode,
442    ) -> Result<Option<PartnerRecord>, EngineError> {
443        self.as_ref().get(tenant_id, mp_id).await
444    }
445
446    async fn remove(
447        &self,
448        tenant_id: TenantId,
449        mp_id: &MarktpartnerCode,
450    ) -> Result<(), EngineError> {
451        self.as_ref().remove(tenant_id, mp_id).await
452    }
453
454    async fn list(&self, tenant_id: TenantId) -> Result<Vec<PartnerRecord>, EngineError> {
455        self.as_ref().list(tenant_id).await
456    }
457}
458
459// ── NoopPartnerStore ──────────────────────────────────────────────────────────
460
461/// A [`PartnerStore`] that never persists anything.
462///
463/// Every `get` returns `None`. Use as the default in deployments that rely
464/// exclusively on static config-based partner lookup (i.e. when
465/// `PartnerDirectory::from_cli_pairs` is sufficient).
466///
467/// ⚠️ **Data loss**: All upserts are silently discarded. PARTIN-derived
468/// updates received at runtime will not be retained across restarts.
469#[cfg_attr(
470    not(any(test, feature = "testing")),
471    deprecated = "NoopPartnerStore must not be instantiated in production builds; \
472                  PARTIN-derived partner updates will be silently discarded. \
473                  Use SlateDbPartnerStore or another durable PartnerStore instead."
474)]
475#[derive(Debug, Clone, Copy, Default)]
476pub struct NoopPartnerStore;
477
478// The `#[allow(deprecated)]` is required because the `deprecated` attribute on
479// `NoopPartnerStore` fires on the impl block inside the same file. This is a
480// known Rust quirk (implementing a deprecated type fires the lint even in the
481// defining module). The guard is still effective: *callers* that instantiate
482// `NoopPartnerStore` outside of test/feature-gated code will see the warning.
483#[cfg(any(test, feature = "testing"))]
484#[allow(deprecated)]
485impl PartnerStore for NoopPartnerStore {
486    async fn upsert(
487        &self,
488        _tenant_id: TenantId,
489        _record: &PartnerRecord,
490    ) -> Result<(), EngineError> {
491        Ok(())
492    }
493
494    async fn get(
495        &self,
496        _tenant_id: TenantId,
497        _mp_id: &MarktpartnerCode,
498    ) -> Result<Option<PartnerRecord>, EngineError> {
499        Ok(None)
500    }
501
502    async fn remove(
503        &self,
504        _tenant_id: TenantId,
505        _mp_id: &MarktpartnerCode,
506    ) -> Result<(), EngineError> {
507        Ok(())
508    }
509
510    async fn list(&self, _tenant_id: TenantId) -> Result<Vec<PartnerRecord>, EngineError> {
511        Ok(vec![])
512    }
513}
514
515// ── InMemoryPartnerStore ──────────────────────────────────────────────────────
516
517/// An in-memory [`PartnerStore`] for tests and development.
518///
519/// Backed by a `HashMap<(TenantId, MarktpartnerCode), PartnerRecord>` protected by an
520/// `Arc<RwLock<…>>`. Clones share the underlying data — all clones see the
521/// same records. Upsert calls `merge_from_partin` for existing records.
522///
523/// Only available in `#[cfg(test)]` or with the `testing` feature enabled.
524#[cfg(any(test, feature = "testing"))]
525#[derive(Debug, Clone, Default)]
526pub struct InMemoryPartnerStore {
527    inner: Arc<RwLock<HashMap<(TenantId, MarktpartnerCode), PartnerRecord>>>,
528}
529
530#[cfg(any(test, feature = "testing"))]
531impl InMemoryPartnerStore {
532    /// Create a new empty store.
533    #[must_use]
534    pub fn new() -> Self {
535        Self::default()
536    }
537}
538
539#[cfg(any(test, feature = "testing"))]
540impl PartnerStore for InMemoryPartnerStore {
541    async fn upsert(&self, tenant_id: TenantId, record: &PartnerRecord) -> Result<(), EngineError> {
542        let mut guard = self.inner.write().await;
543        let key = (tenant_id, record.mp_id.clone());
544        match guard.get_mut(&key) {
545            Some(existing) => existing.merge_from_partin(record.clone()),
546            None => {
547                guard.insert(key, record.clone());
548            }
549        }
550        Ok(())
551    }
552
553    async fn get(
554        &self,
555        tenant_id: TenantId,
556        mp_id: &MarktpartnerCode,
557    ) -> Result<Option<PartnerRecord>, EngineError> {
558        Ok(self
559            .inner
560            .read()
561            .await
562            .get(&(tenant_id, mp_id.clone()))
563            .cloned())
564    }
565
566    async fn remove(
567        &self,
568        tenant_id: TenantId,
569        mp_id: &MarktpartnerCode,
570    ) -> Result<(), EngineError> {
571        self.inner.write().await.remove(&(tenant_id, mp_id.clone()));
572        Ok(())
573    }
574
575    async fn list(&self, tenant_id: TenantId) -> Result<Vec<PartnerRecord>, EngineError> {
576        Ok(self
577            .inner
578            .read()
579            .await
580            .iter()
581            .filter(|((tid, _), _)| *tid == tenant_id)
582            .map(|(_, record)| record.clone())
583            .collect())
584    }
585}
586
587// ── Tests ─────────────────────────────────────────────────────────────────────
588
589#[cfg(test)]
590mod tests {
591    use super::*;
592
593    fn mp_id(s: &str) -> MarktpartnerCode {
594        MarktpartnerCode::new(s)
595    }
596    fn tid() -> TenantId {
597        TenantId::new()
598    }
599
600    fn minimal_record(gln_str: &str, url: &str) -> PartnerRecord {
601        PartnerRecord::minimal(mp_id(gln_str), url)
602    }
603
604    // ── from_cli_pairs ────────────────────────────────────────────────────────
605
606    #[test]
607    fn from_cli_pairs_parses_valid_entries() {
608        let pairs = vec![
609            "9900000000002=https://partner-a.example/as4/inbox",
610            "9900000000003=https://partner-b.example/as4/inbox",
611        ];
612        let records = PartnerRecord::from_cli_pairs(&pairs).unwrap();
613        assert_eq!(records.len(), 2);
614        assert_eq!(records[0].mp_id.as_str(), "9900000000002");
615        assert_eq!(
616            records[0].as4_endpoint(),
617            Some("https://partner-a.example/as4/inbox")
618        );
619        assert_eq!(records[1].mp_id.as_str(), "9900000000003");
620    }
621
622    #[test]
623    fn from_cli_pairs_rejects_missing_equals() {
624        let pairs = vec!["9900000000002https://no-equals.example"];
625        assert!(PartnerRecord::from_cli_pairs(&pairs).is_err());
626    }
627
628    #[test]
629    fn from_cli_pairs_rejects_http_url() {
630        let pairs = vec!["9900000000002=http://insecure.example/as4"];
631        assert!(PartnerRecord::from_cli_pairs(&pairs).is_err());
632    }
633
634    #[test]
635    fn from_cli_pairs_rejects_empty_gln() {
636        let pairs = vec!["=https://no-mp_id.example/as4"];
637        assert!(PartnerRecord::from_cli_pairs(&pairs).is_err());
638    }
639
640    // ── as4_endpoint ──────────────────────────────────────────────────────────
641
642    #[test]
643    fn as4_endpoint_returns_ak_channel() {
644        let r = minimal_record("9900000000002", "https://a.example/as4");
645        assert_eq!(r.as4_endpoint(), Some("https://a.example/as4"));
646    }
647
648    #[test]
649    fn as4_endpoint_returns_none_when_absent() {
650        let r = PartnerRecord {
651            mp_id: mp_id("9900000000002"),
652            display_name: None,
653            channels: vec![CommunicationChannel::email("info@example.de")],
654            roles: vec![],
655            valid_from: None,
656            contacts: vec![],
657            country_code: None,
658            updated_at: OffsetDateTime::now_utc(),
659        };
660        assert!(r.as4_endpoint().is_none());
661    }
662
663    // ── merge_from_partin ─────────────────────────────────────────────────────
664
665    #[test]
666    fn merge_overwrites_config_record_with_partin_data() {
667        let mut base = minimal_record("9900000000002", "https://old.example/as4");
668        let newer = PartnerRecord {
669            mp_id: mp_id("9900000000002"),
670            display_name: Some("Stadtwerke AG".into()),
671            channels: vec![
672                CommunicationChannel::as4("https://new.example/as4"),
673                CommunicationChannel::email("edifact@sw.example"),
674            ],
675            roles: vec![Marktrolle::Nb],
676            valid_from: Some(OffsetDateTime::now_utc()),
677            contacts: vec![],
678            country_code: Some("DE".into()),
679            updated_at: OffsetDateTime::now_utc(),
680        };
681        base.merge_from_partin(newer.clone());
682        assert_eq!(base.as4_endpoint(), Some("https://new.example/as4"));
683        assert_eq!(base.display_name.as_deref(), Some("Stadtwerke AG"));
684        assert_eq!(base.roles, vec![Marktrolle::Nb]);
685    }
686
687    #[test]
688    fn merge_ignores_older_partin() {
689        use time::Duration;
690        let old_ts = OffsetDateTime::now_utc() - Duration::days(30);
691        let new_ts = OffsetDateTime::now_utc();
692
693        let mut current = PartnerRecord {
694            mp_id: mp_id("9900000000002"),
695            display_name: Some("Current Name".into()),
696            channels: vec![CommunicationChannel::as4("https://current.example/as4")],
697            roles: vec![Marktrolle::Nb],
698            valid_from: Some(new_ts),
699            contacts: vec![],
700            country_code: Some("DE".into()),
701            updated_at: OffsetDateTime::now_utc(),
702        };
703
704        let stale = PartnerRecord {
705            mp_id: mp_id("9900000000002"),
706            display_name: Some("Stale Name".into()),
707            channels: vec![CommunicationChannel::as4("https://stale.example/as4")],
708            roles: vec![],
709            valid_from: Some(old_ts),
710            contacts: vec![],
711            country_code: None,
712            updated_at: OffsetDateTime::now_utc(),
713        };
714
715        current.merge_from_partin(stale);
716        // Should not be overwritten
717        assert_eq!(current.display_name.as_deref(), Some("Current Name"));
718        assert_eq!(current.as4_endpoint(), Some("https://current.example/as4"));
719    }
720
721    #[test]
722    fn merge_ignores_wrong_gln() {
723        let mut r = minimal_record("9900000000002", "https://a.example/as4");
724        let other = minimal_record("9900000000003", "https://b.example/as4");
725        r.merge_from_partin(other);
726        assert_eq!(r.as4_endpoint(), Some("https://a.example/as4"));
727    }
728
729    // ── roles serde (BDEW codes) ──────────────────────────────────────────────
730
731    #[test]
732    fn roles_serialize_as_bdew_codes() {
733        let mut r = minimal_record("9900000000002", "https://a.example/as4");
734        r.roles = vec![Marktrolle::Nb, Marktrolle::Msb];
735        let json = serde_json::to_value(&r).unwrap();
736        assert_eq!(json["roles"], serde_json::json!(["NB", "MSB"]));
737        let back: PartnerRecord = serde_json::from_value(json).unwrap();
738        assert_eq!(back.roles, vec![Marktrolle::Nb, Marktrolle::Msb]);
739    }
740
741    // ── InMemoryPartnerStore ──────────────────────────────────────────────────
742
743    #[tokio::test]
744    async fn in_memory_upsert_and_get() {
745        let store = InMemoryPartnerStore::new();
746        let tenant = tid();
747        let record = minimal_record("9900000000001", "https://a.example/as4");
748
749        store.upsert(tenant, &record).await.unwrap();
750        let found = store
751            .get(tenant, &mp_id("9900000000001"))
752            .await
753            .unwrap()
754            .unwrap();
755        assert_eq!(found.as4_endpoint(), Some("https://a.example/as4"));
756    }
757
758    #[tokio::test]
759    async fn in_memory_get_returns_none_for_unknown() {
760        let store = InMemoryPartnerStore::new();
761        assert!(
762            store
763                .get(tid(), &mp_id("9900000000099"))
764                .await
765                .unwrap()
766                .is_none()
767        );
768    }
769
770    #[tokio::test]
771    async fn in_memory_upsert_merges_into_existing() {
772        let store = InMemoryPartnerStore::new();
773        let tenant = tid();
774        let base = minimal_record("9900000000001", "https://old.example/as4");
775        store.upsert(tenant, &base).await.unwrap();
776
777        let newer = PartnerRecord {
778            mp_id: mp_id("9900000000001"),
779            display_name: Some("Partner AG".into()),
780            channels: vec![CommunicationChannel::as4("https://new.example/as4")],
781            roles: vec![Marktrolle::Lf],
782            valid_from: Some(OffsetDateTime::now_utc()),
783            contacts: vec![],
784            country_code: Some("DE".into()),
785            updated_at: OffsetDateTime::now_utc(),
786        };
787        store.upsert(tenant, &newer).await.unwrap();
788
789        let found = store
790            .get(tenant, &mp_id("9900000000001"))
791            .await
792            .unwrap()
793            .unwrap();
794        assert_eq!(found.as4_endpoint(), Some("https://new.example/as4"));
795        assert_eq!(found.display_name.as_deref(), Some("Partner AG"));
796    }
797
798    #[tokio::test]
799    async fn in_memory_remove_clears_record() {
800        let store = InMemoryPartnerStore::new();
801        let tenant = tid();
802        let record = minimal_record("9900000000001", "https://a.example/as4");
803
804        store.upsert(tenant, &record).await.unwrap();
805        store.remove(tenant, &mp_id("9900000000001")).await.unwrap();
806        assert!(
807            store
808                .get(tenant, &mp_id("9900000000001"))
809                .await
810                .unwrap()
811                .is_none()
812        );
813    }
814
815    #[tokio::test]
816    async fn in_memory_list_is_tenant_scoped() {
817        let store = InMemoryPartnerStore::new();
818        let t1 = tid();
819        let t2 = tid();
820
821        store
822            .upsert(
823                t1,
824                &minimal_record("9900000000001", "https://a.example/as4"),
825            )
826            .await
827            .unwrap();
828        store
829            .upsert(
830                t2,
831                &minimal_record("9900000000002", "https://b.example/as4"),
832            )
833            .await
834            .unwrap();
835
836        let t1_list = store.list(t1).await.unwrap();
837        assert_eq!(t1_list.len(), 1);
838        assert_eq!(t1_list[0].mp_id.as_str(), "9900000000001");
839
840        let t2_list = store.list(t2).await.unwrap();
841        assert_eq!(t2_list.len(), 1);
842        assert_eq!(t2_list[0].mp_id.as_str(), "9900000000002");
843    }
844
845    #[tokio::test]
846    async fn as4_endpoint_convenience_method() {
847        let store = InMemoryPartnerStore::new();
848        let tenant = tid();
849        let record = minimal_record("9900000000001", "https://a.example/as4");
850
851        store.upsert(tenant, &record).await.unwrap();
852        let url = store
853            .as4_endpoint(tenant, &mp_id("9900000000001"))
854            .await
855            .unwrap();
856        assert_eq!(url.as_deref(), Some("https://a.example/as4"));
857
858        let none = store
859            .as4_endpoint(tenant, &mp_id("9900000000099"))
860            .await
861            .unwrap();
862        assert!(none.is_none());
863    }
864}