Skip to main content

sipx_ua/
presence.rs

1//! Presence, and publishing it (RFC 3856, RFC 3863, RFC 3903).
2//!
3//! `S-17`'s packages report state sipx already keeps. Presence has none — nothing in a SIP stack
4//! knows whether a person is at their desk — so this is the half that lets somebody who *does*
5//! know put it in: PUBLISH creates soft state, an entity tag identifies it, and a subscriber to the
6//! `presence` package is told when it changes.
7//!
8//! **The entity tag is the whole mechanism and the part that is easy to skip.** Without it two
9//! publishers for one resource silently overwrite each other and neither can tell; with it, a
10//! publisher whose state has expired is told to start again rather than allowed to resurrect a
11//! document the server has already forgotten.
12//! **Supported** (`S-35`, `S-39`): `sipx-call` selects [`Pidf`] for its live notifier and carries
13//! this exact compositor through live inbound PUBLISH. Breaking changes receive migration guidance
14//! while sipx remains pre-1.0. No CLI command publishes, and projection from this store into later
15//! presence NOTIFY documents remains an application policy.
16//!
17
18use std::fmt::Write as _;
19use std::time::Duration;
20
21/// The MIME type a presence document carries (RFC 3863 §4).
22pub const PIDF_TYPE: &str = "application/pidf+xml";
23
24/// Whether a contact is reachable (RFC 3863 §4.1.3).
25///
26/// Two values, and only two. §4.1.3 defines `open` and `closed` and nothing else; the rich
27/// vocabulary people expect — busy, away, on the phone — is RFC 4480's, which is a separate
28/// document and a separate story.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Basic {
31    /// Reachable.
32    Open,
33    /// Not.
34    Closed,
35}
36
37impl Basic {
38    /// The token as it appears in the document.
39    #[must_use]
40    pub fn as_str(self) -> &'static str {
41        match self {
42            Self::Open => "open",
43            Self::Closed => "closed",
44        }
45    }
46}
47
48/// One way of reaching a presentity (RFC 3863 §4.1.2).
49#[derive(Debug, Clone, PartialEq)]
50pub struct Tuple {
51    /// Identifies this tuple within the document.
52    pub id: String,
53    /// Whether it is reachable.
54    pub status: Basic,
55    /// Where, if the tuple names somewhere.
56    pub contact: Option<String>,
57    /// How much this contact is preferred, in `0.0..=1.0` (§4.1.4).
58    pub priority: Option<f32>,
59    /// Free text for a human.
60    pub note: Option<String>,
61}
62
63impl Tuple {
64    /// A reachable tuple.
65    #[must_use]
66    pub fn open(id: impl Into<String>) -> Self {
67        Self {
68            id: id.into(),
69            status: Basic::Open,
70            contact: None,
71            priority: None,
72            note: None,
73        }
74    }
75
76    /// An unreachable one.
77    #[must_use]
78    pub fn closed(id: impl Into<String>) -> Self {
79        Self {
80            id: id.into(),
81            status: Basic::Closed,
82            contact: None,
83            priority: None,
84            note: None,
85        }
86    }
87
88    /// Reachable at this URI.
89    #[must_use]
90    pub fn at(mut self, contact: impl Into<String>) -> Self {
91        self.contact = Some(contact.into());
92        self
93    }
94
95    /// With this preference.
96    #[must_use]
97    pub fn with_priority(mut self, priority: f32) -> Self {
98        // Clamped rather than trusted: §4.1.4 fixes the range, and a document carrying 7.5 is one
99        // a watcher may reject outright — losing the whole presence rather than one number.
100        self.priority = Some(priority.clamp(0.0, 1.0));
101        self
102    }
103
104    /// With a note for a human.
105    #[must_use]
106    pub fn with_note(mut self, note: impl Into<String>) -> Self {
107        self.note = Some(note.into());
108        self
109    }
110}
111
112/// A presence document (RFC 3863).
113///
114/// A typed document rather than a string template, which is what the story asked for and what
115/// stops a caller producing something unparseable by concatenation.
116#[derive(Debug, Clone, PartialEq)]
117pub struct Pidf {
118    /// Whose presence this is.
119    pub entity: String,
120    /// The ways of reaching them.
121    pub tuples: Vec<Tuple>,
122}
123
124impl Pidf {
125    /// A document for a presentity.
126    #[must_use]
127    pub fn new(entity: impl Into<String>) -> Self {
128        Self {
129            entity: entity.into(),
130            tuples: Vec::new(),
131        }
132    }
133
134    /// With this tuple.
135    #[must_use]
136    pub fn with(mut self, tuple: Tuple) -> Self {
137        self.tuples.push(tuple);
138        self
139    }
140
141    /// Render as `application/pidf+xml`.
142    #[must_use]
143    pub fn to_xml(&self) -> String {
144        let mut out = String::with_capacity(256);
145        out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
146        let _ = write!(
147            out,
148            "<presence xmlns=\"urn:ietf:params:xml:ns:pidf\" entity=\"{}\">",
149            escape(&self.entity)
150        );
151        for tuple in &self.tuples {
152            let _ = write!(out, "\n  <tuple id=\"{}\">", escape(&tuple.id));
153            let _ = write!(
154                out,
155                "\n    <status>\n      <basic>{}</basic>\n    </status>",
156                tuple.status.as_str()
157            );
158            if let Some(contact) = &tuple.contact {
159                match tuple.priority {
160                    Some(priority) => {
161                        let _ = write!(
162                            out,
163                            "\n    <contact priority=\"{priority}\">{}</contact>",
164                            escape(contact)
165                        );
166                    }
167                    None => {
168                        let _ = write!(out, "\n    <contact>{}</contact>", escape(contact));
169                    }
170                }
171            }
172            if let Some(note) = &tuple.note {
173                let _ = write!(out, "\n    <note>{}</note>", escape(note));
174            }
175            out.push_str("\n  </tuple>");
176        }
177        out.push_str("\n</presence>\n");
178        out
179    }
180}
181
182/// What a PUBLISH asked for (RFC 3903 §4.1, §6).
183///
184/// The three operations differ only by what is present, which is why they are read as one thing
185/// and dispatched on: an entity tag with no body is a refresh, with a body a modify, and with
186/// `Expires: 0` a removal.
187#[derive(Debug, Clone, PartialEq)]
188pub enum Publish {
189    /// First publication: a body and no entity tag.
190    Initial {
191        /// The document.
192        body: String,
193        /// How long it should live.
194        expires: Duration,
195    },
196    /// A refresh: an entity tag and no body.
197    Refresh {
198        /// The tag identifying the state.
199        tag: String,
200        /// The new lifetime.
201        expires: Duration,
202    },
203    /// A modification: an entity tag and a body.
204    Modify {
205        /// The tag identifying the state.
206        tag: String,
207        /// The replacement document.
208        body: String,
209        /// The new lifetime.
210        expires: Duration,
211    },
212    /// A removal: an entity tag and `Expires: 0`.
213    Remove {
214        /// The tag identifying the state.
215        tag: String,
216    },
217    /// Neither a body nor a tag — §6 step 5 says reject this.
218    ///
219    /// It is not an empty publication: there is nothing to publish and nothing to identify, so
220    /// there is no operation it could be.
221    Empty,
222}
223
224impl Publish {
225    /// Read what a PUBLISH is asking for, from its tag, body and expiry.
226    #[must_use]
227    pub fn read(tag: Option<String>, body: Option<String>, expires: Duration) -> Self {
228        match (tag, body) {
229            (Some(tag), _) if expires.is_zero() => Self::Remove { tag },
230            (Some(tag), Some(body)) => Self::Modify { tag, body, expires },
231            (Some(tag), None) => Self::Refresh { tag, expires },
232            (None, Some(body)) => Self::Initial { body, expires },
233            (None, None) => Self::Empty,
234        }
235    }
236}
237
238/// How a publication attempt was answered.
239#[derive(Debug, Clone, PartialEq, Eq)]
240pub enum Published {
241    /// Accepted. The tag identifies the state from now on and goes in `SIP-ETag` (§6 step 6).
242    ///
243    /// A **fresh** tag on every acceptance, including a refresh: §6 has the ESC issue one per
244    /// response, and a publisher that kept using an old one after a refresh would be rejected the
245    /// next time it tried.
246    Accepted {
247        /// The new entity tag.
248        tag: String,
249        /// How long the state will live.
250        expires: Duration,
251    },
252    /// Removed. Its fresh response tag identifies no retained state because the granted lifetime
253    /// is zero.
254    Removed {
255        /// The new entity tag required on every successful response.
256        tag: String,
257    },
258    /// The entity tag names state this server does not have (§6 step 3).
259    ///
260    /// **412, not 404 and not silently accepting it as new.** A publisher whose state expired
261    /// while it was not looking has to start again with a fresh publication; treating its refresh
262    /// as a new publication would resurrect a document the server had already forgotten and that
263    /// nothing has re-sent.
264    ConditionFailed,
265    /// Nothing to publish and nothing to identify (§6 step 5).
266    Invalid,
267    /// The compositor could not mint another unique entity tag.
268    Unavailable,
269}
270
271/// The status a stale entity tag is refused with (RFC 3903 §6 step 3).
272pub const CONDITIONAL_REQUEST_FAILED: u16 = 412;
273
274/// The soft state one event state compositor holds.
275///
276/// "Soft" is the whole model: a publication expires unless refreshed, so a publisher that
277/// disappears stops being believed rather than leaving a presence nobody can clear.
278#[derive(Debug)]
279pub struct Compositor {
280    held: Vec<Entry>,
281    next_tag: u64,
282    maximum: Duration,
283}
284
285#[derive(Debug)]
286struct Entry {
287    tag: String,
288    entity: String,
289    body: String,
290    expires_at: u64,
291}
292
293impl Compositor {
294    /// A compositor granting at most this long.
295    #[must_use]
296    pub fn new(maximum: Duration) -> Self {
297        Self {
298            held: Vec::new(),
299            next_tag: 0,
300            maximum,
301        }
302    }
303
304    /// How many publications are held.
305    #[must_use]
306    pub fn len(&self) -> usize {
307        self.held.len()
308    }
309
310    /// Whether nothing is published.
311    #[must_use]
312    pub fn is_empty(&self) -> bool {
313        self.held.is_empty()
314    }
315
316    /// The document currently published for a presentity, if any.
317    #[must_use]
318    pub fn document(&self, entity: &str) -> Option<&str> {
319        self.held
320            .iter()
321            .find(|entry| entry.entity == entity)
322            .map(|entry| entry.body.as_str())
323    }
324
325    /// Apply a publication.
326    pub fn apply(&mut self, entity: &str, publish: Publish, now: u64) -> Published {
327        match publish {
328            Publish::Empty => Published::Invalid,
329            Publish::Initial { body, expires } => {
330                let expires = expires.min(self.maximum);
331                let Some(tag) = self.mint() else {
332                    return Published::Unavailable;
333                };
334                // A second publication for one presentity replaces the first. Composing several
335                // publishers' documents is what the RFC calls composition policy, and it is a
336                // policy question rather than a mechanism — so it belongs to whoever has one.
337                self.held.retain(|entry| entry.entity != entity);
338                self.held.push(Entry {
339                    tag: tag.clone(),
340                    entity: entity.to_owned(),
341                    body,
342                    expires_at: now.saturating_add(expires.as_secs()),
343                });
344                Published::Accepted { tag, expires }
345            }
346            Publish::Refresh { tag, expires } => {
347                let expires = expires.min(self.maximum);
348                let Some(index) = self.find(entity, &tag, now) else {
349                    return Published::ConditionFailed;
350                };
351                let Some(fresh) = self.mint() else {
352                    return Published::Unavailable;
353                };
354                let Some(entry) = self.held.get_mut(index) else {
355                    return Published::ConditionFailed;
356                };
357                entry.tag.clone_from(&fresh);
358                entry.expires_at = now.saturating_add(expires.as_secs());
359                Published::Accepted {
360                    tag: fresh,
361                    expires,
362                }
363            }
364            Publish::Modify { tag, body, expires } => {
365                let expires = expires.min(self.maximum);
366                let Some(index) = self.find(entity, &tag, now) else {
367                    return Published::ConditionFailed;
368                };
369                let Some(fresh) = self.mint() else {
370                    return Published::Unavailable;
371                };
372                let Some(entry) = self.held.get_mut(index) else {
373                    return Published::ConditionFailed;
374                };
375                entry.tag.clone_from(&fresh);
376                entry.body = body;
377                entry.expires_at = now.saturating_add(expires.as_secs());
378                Published::Accepted {
379                    tag: fresh,
380                    expires,
381                }
382            }
383            Publish::Remove { tag } => {
384                let Some(index) = self.find(entity, &tag, now) else {
385                    return Published::ConditionFailed;
386                };
387                let Some(fresh) = self.mint() else {
388                    return Published::Unavailable;
389                };
390                self.held.remove(index);
391                Published::Removed { tag: fresh }
392            }
393        }
394    }
395
396    /// Forget everything that has run out of time.
397    pub fn expire(&mut self, now: u64) -> usize {
398        let before = self.held.len();
399        self.held.retain(|entry| entry.expires_at > now);
400        before - self.held.len()
401    }
402
403    /// The index of live state with this tag.
404    ///
405    /// Expiry is checked here rather than only in `expire`, so a refresh arriving after the state
406    /// lapsed is refused whether or not anyone has swept yet. Otherwise whether a publisher is
407    /// told 412 would depend on how recently a timer ran.
408    fn find(&self, entity: &str, tag: &str, now: u64) -> Option<usize> {
409        self.held
410            .iter()
411            .position(|entry| entry.entity == entity && entry.tag == tag && entry.expires_at > now)
412    }
413
414    fn mint(&mut self) -> Option<String> {
415        self.next_tag = self.next_tag.checked_add(1)?;
416        Some(format!("sipx-{:016x}", self.next_tag))
417    }
418}
419
420fn escape(value: &str) -> String {
421    let mut out = String::with_capacity(value.len());
422    for character in value.chars() {
423        match character {
424            '&' => out.push_str("&amp;"),
425            '<' => out.push_str("&lt;"),
426            '>' => out.push_str("&gt;"),
427            '"' => out.push_str("&quot;"),
428            '\'' => out.push_str("&apos;"),
429            other => out.push(other),
430        }
431    }
432    out
433}
434
435#[cfg(test)]
436#[allow(
437    clippy::unwrap_used,
438    clippy::expect_used,
439    clippy::panic,
440    clippy::indexing_slicing
441)]
442mod tests {
443    use super::*;
444    use sipx_sip::event::Packages;
445
446    const NOW: u64 = 1_700_000_000;
447
448    fn document() -> String {
449        Pidf::new("sip:alice@sipx.test")
450            .with(Tuple::open("t1").at("sip:alice@192.0.2.5"))
451            .to_xml()
452    }
453
454    fn compositor() -> Compositor {
455        Compositor::new(Duration::from_secs(3600))
456    }
457
458    /// The story's failing-first test.
459    ///
460    /// Somebody publishes presence; a watcher subscribed to the `presence` package gets the
461    /// document. That is the whole chain, and every piece of it is here rather than assumed.
462    #[test]
463    fn a_published_presence_document_reaches_a_subscriber() {
464        // The notifier serves `presence`, so a SUBSCRIBE for it is not refused (`S-13`).
465        let packages = Packages::new().with("presence");
466        assert!(packages.serves("presence"));
467
468        let mut compositor = compositor();
469        let published = compositor.apply(
470            "sip:alice@sipx.test",
471            Publish::read(None, Some(document()), Duration::from_secs(600)),
472            NOW,
473        );
474        assert!(matches!(published, Published::Accepted { .. }));
475
476        // What a NOTIFY for that subscription would carry.
477        let notified = compositor
478            .document("sip:alice@sipx.test")
479            .expect("a subscriber gets the document that was published");
480        assert!(notified.contains("<basic>open</basic>"), "{notified}");
481        assert!(
482            notified.contains("sip:alice@192.0.2.5"),
483            "and the contact it named: {notified}"
484        );
485    }
486
487    /// §4.1: the three operations differ by what is present.
488    #[test]
489    fn the_operation_is_read_from_the_tag_the_body_and_the_expiry() {
490        let hour = Duration::from_secs(3600);
491        assert!(matches!(
492            Publish::read(None, Some("doc".to_owned()), hour),
493            Publish::Initial { .. }
494        ));
495        assert!(matches!(
496            Publish::read(Some("t".to_owned()), None, hour),
497            Publish::Refresh { .. }
498        ));
499        assert!(matches!(
500            Publish::read(Some("t".to_owned()), Some("doc".to_owned()), hour),
501            Publish::Modify { .. }
502        ));
503        assert!(matches!(
504            Publish::read(Some("t".to_owned()), None, Duration::ZERO),
505            Publish::Remove { .. }
506        ));
507        // §6 step 5: neither a body nor a tag is not an empty publication — there is nothing to
508        // publish and nothing to identify, so there is no operation it could be.
509        assert_eq!(Publish::read(None, None, hour), Publish::Empty);
510    }
511
512    /// §6 step 3: "If no match is found, the ESC MUST reject the publication with a response of
513    /// 412 (Conditional Request Failed)".
514    #[test]
515    fn a_tag_this_server_does_not_hold_is_refused_with_412() {
516        let mut compositor = compositor();
517        assert_eq!(
518            compositor.apply(
519                "sip:alice@sipx.test",
520                Publish::read(
521                    Some("nobody-issued-this".to_owned()),
522                    None,
523                    Duration::from_secs(600)
524                ),
525                NOW,
526            ),
527            Published::ConditionFailed
528        );
529        assert_eq!(CONDITIONAL_REQUEST_FAILED, 412);
530    }
531
532    /// The case the story singles out: state that expired while the publisher was not looking.
533    /// Accepting the refresh as a new publication would resurrect a document the server had
534    /// already forgotten and that nothing has re-sent.
535    #[test]
536    fn a_refresh_of_expired_state_is_refused_rather_than_accepted_as_new() {
537        let mut compositor = compositor();
538        let Published::Accepted { tag, .. } = compositor.apply(
539            "sip:alice@sipx.test",
540            Publish::read(None, Some(document()), Duration::from_secs(60)),
541            NOW,
542        ) else {
543            panic!("the first publication is accepted");
544        };
545
546        let refresh = Publish::read(Some(tag), None, Duration::from_secs(60));
547        assert_eq!(
548            compositor.apply("sip:alice@sipx.test", refresh, NOW + 61),
549            Published::ConditionFailed,
550            "the state had lapsed; the publisher must start again"
551        );
552    }
553
554    /// And it is refused whether or not anyone has swept — otherwise whether a publisher is told
555    /// 412 depends on how recently a timer ran.
556    #[test]
557    fn expiry_is_judged_on_the_clock_and_not_on_whether_a_sweep_has_happened() {
558        let mut compositor = compositor();
559        let Published::Accepted { tag, .. } = compositor.apply(
560            "sip:alice@sipx.test",
561            Publish::read(None, Some(document()), Duration::from_secs(60)),
562            NOW,
563        ) else {
564            panic!("accepted");
565        };
566        assert_eq!(compositor.len(), 1, "nothing has swept it yet");
567        assert_eq!(
568            compositor.apply(
569                "sip:alice@sipx.test",
570                Publish::read(Some(tag), None, Duration::from_secs(60)),
571                NOW + 61
572            ),
573            Published::ConditionFailed
574        );
575    }
576
577    /// §6 step 6: the response carries a `SIP-ETag`, and a fresh one each time. A publisher that
578    /// kept using its old tag after a refresh would be rejected on its next attempt.
579    #[test]
580    fn every_acceptance_issues_a_new_tag() {
581        let mut compositor = compositor();
582        let Published::Accepted { tag: first, .. } = compositor.apply(
583            "sip:alice@sipx.test",
584            Publish::read(None, Some(document()), Duration::from_secs(600)),
585            NOW,
586        ) else {
587            panic!("accepted");
588        };
589        let Published::Accepted { tag: second, .. } = compositor.apply(
590            "sip:alice@sipx.test",
591            Publish::read(Some(first.clone()), None, Duration::from_secs(600)),
592            NOW + 10,
593        ) else {
594            panic!("the refresh is accepted");
595        };
596        assert_ne!(first, second, "a refresh issues a fresh tag");
597
598        // And the old tag is no longer good, which is what makes the new one meaningful.
599        assert_eq!(
600            compositor.apply(
601                "sip:alice@sipx.test",
602                Publish::read(Some(first), None, Duration::from_secs(600)),
603                NOW + 20
604            ),
605            Published::ConditionFailed
606        );
607    }
608
609    /// §6 step 5: `Expires: 0` removes the state the tag identifies.
610    #[test]
611    fn expires_zero_removes_the_publication() {
612        let mut compositor = compositor();
613        let Published::Accepted { tag, .. } = compositor.apply(
614            "sip:alice@sipx.test",
615            Publish::read(None, Some(document()), Duration::from_secs(600)),
616            NOW,
617        ) else {
618            panic!("accepted");
619        };
620        assert!(matches!(
621            compositor.apply(
622                "sip:alice@sipx.test",
623                Publish::read(Some(tag), None, Duration::ZERO),
624                NOW
625            ),
626            Published::Removed { tag: _ }
627        ));
628        assert!(compositor.is_empty());
629        assert!(compositor.document("sip:alice@sipx.test").is_none());
630    }
631
632    #[test]
633    fn a_modification_replaces_the_document() {
634        let mut compositor = compositor();
635        let Published::Accepted { tag, .. } = compositor.apply(
636            "sip:alice@sipx.test",
637            Publish::read(None, Some(document()), Duration::from_secs(600)),
638            NOW,
639        ) else {
640            panic!("accepted");
641        };
642        let away = Pidf::new("sip:alice@sipx.test")
643            .with(Tuple::closed("t1").with_note("in a meeting"))
644            .to_xml();
645        assert!(matches!(
646            compositor.apply(
647                "sip:alice@sipx.test",
648                Publish::read(Some(tag), Some(away), Duration::from_secs(600)),
649                NOW
650            ),
651            Published::Accepted { .. }
652        ));
653        let held = compositor
654            .document("sip:alice@sipx.test")
655            .expect("a document");
656        assert!(held.contains("<basic>closed</basic>"), "{held}");
657        assert!(held.contains("in a meeting"), "{held}");
658    }
659
660    /// A publication with neither a body nor a tag is refused (§6 step 5).
661    #[test]
662    fn a_publication_with_nothing_in_it_is_invalid() {
663        let mut compositor = compositor();
664        assert_eq!(
665            compositor.apply(
666                "sip:alice@sipx.test",
667                Publish::read(None, None, Duration::from_secs(600)),
668                NOW
669            ),
670            Published::Invalid
671        );
672        assert!(compositor.is_empty());
673    }
674
675    #[test]
676    fn a_pidf_document_is_typed_rather_than_concatenated() {
677        let xml = Pidf::new("sip:alice@sipx.test")
678            .with(
679                Tuple::open("t1")
680                    .at("sip:alice@192.0.2.5")
681                    .with_priority(0.8)
682                    .with_note("at my desk"),
683            )
684            .to_xml();
685        assert!(
686            xml.contains("xmlns=\"urn:ietf:params:xml:ns:pidf\""),
687            "{xml}"
688        );
689        assert!(xml.contains("entity=\"sip:alice@sipx.test\""), "{xml}");
690        assert!(xml.contains("<basic>open</basic>"), "{xml}");
691        assert!(xml.contains("priority=\"0.8\""), "{xml}");
692        assert!(xml.contains("<note>at my desk</note>"), "{xml}");
693        assert_eq!(PIDF_TYPE, "application/pidf+xml");
694    }
695
696    /// §4.1.4 fixes the priority range. A document carrying 7.5 is one a watcher may reject
697    /// outright, losing the whole presence rather than one number.
698    #[test]
699    fn a_priority_outside_the_range_is_clamped_rather_than_emitted() {
700        let tuple = Tuple::open("t1").at("sip:a@b").with_priority(7.5);
701        assert_eq!(tuple.priority, Some(1.0));
702        let low = Tuple::open("t1").at("sip:a@b").with_priority(-3.0);
703        assert_eq!(low.priority, Some(0.0));
704    }
705
706    #[test]
707    fn xml_metacharacters_in_a_note_do_not_break_the_document() {
708        let xml = Pidf::new("sip:alice@sipx.test")
709            .with(Tuple::open("t1").with_note("tea & <biscuits>"))
710            .to_xml();
711        assert!(xml.contains("tea &amp; &lt;biscuits&gt;"), "{xml}");
712        assert!(!xml.contains("<biscuits>"), "{xml}");
713    }
714
715    #[test]
716    fn a_second_publication_for_one_presentity_replaces_the_first() {
717        let mut compositor = compositor();
718        let _ = compositor.apply(
719            "sip:alice@sipx.test",
720            Publish::read(None, Some(document()), Duration::from_secs(600)),
721            NOW,
722        );
723        let _ = compositor.apply(
724            "sip:alice@sipx.test",
725            Publish::read(None, Some(document()), Duration::from_secs(600)),
726            NOW,
727        );
728        assert_eq!(
729            compositor.len(),
730            1,
731            "one presentity, one published document"
732        );
733    }
734
735    #[test]
736    fn the_compositor_shortens_a_generous_expiry_to_its_maximum() {
737        let mut compositor = Compositor::new(Duration::from_secs(300));
738        let Published::Accepted { expires, .. } = compositor.apply(
739            "sip:alice@sipx.test",
740            Publish::read(None, Some(document()), Duration::from_secs(86400)),
741            NOW,
742        ) else {
743            panic!("accepted");
744        };
745        assert_eq!(expires, Duration::from_secs(300));
746    }
747
748    #[test]
749    fn expiring_forgets_what_has_run_out() {
750        let mut compositor = compositor();
751        let _ = compositor.apply(
752            "sip:alice@sipx.test",
753            Publish::read(None, Some(document()), Duration::from_secs(60)),
754            NOW,
755        );
756        assert_eq!(compositor.expire(NOW + 30), 0, "not yet");
757        assert_eq!(compositor.expire(NOW + 61), 1);
758        assert!(compositor.is_empty());
759    }
760}