use thiserror::Error;
use crate::event::{Event, EventBuilder, Tag, TagKind};
use crate::types::{Timestamp, TimestampError};
pub const EXPIRATION_TAG: &str = "expiration";
#[derive(Debug, Clone, Error)]
#[non_exhaustive]
pub enum ExpirationError {
#[error("`expiration` tag is missing the timestamp value")]
MissingValue,
#[error("`expiration` tag value `{0}` is not a valid unix timestamp")]
InvalidTimestamp(String),
}
pub fn parse_expiration(event: &Event) -> Result<Option<Timestamp>, ExpirationError> {
let kind = TagKind::from_wire(EXPIRATION_TAG);
let Some(tag) = event.tags.find_first(&kind) else {
return Ok(None);
};
let Some(value) = tag.values().get(1) else {
return Err(ExpirationError::MissingValue);
};
let secs: u64 = value
.parse()
.map_err(|_| ExpirationError::InvalidTimestamp(value.clone()))?;
Ok(Some(Timestamp::from_secs(secs)))
}
pub fn is_expired(event: &Event, now: Timestamp) -> Result<bool, ExpirationError> {
Ok(parse_expiration(event)?.is_some_and(|deadline| now >= deadline))
}
pub fn is_expired_now(event: &Event) -> Result<bool, IsExpiredError> {
let now = Timestamp::now()?;
Ok(is_expired(event, now)?)
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum IsExpiredError {
#[error(transparent)]
Expiration(#[from] ExpirationError),
#[error(transparent)]
Clock(#[from] TimestampError),
}
impl EventBuilder {
#[must_use]
pub fn expiration(mut self, ts: Timestamp) -> Self {
let kind = TagKind::from_wire(EXPIRATION_TAG);
let tag = Tag::with(&kind, [ts.as_secs().to_string()]);
self.tags_mut().replace_or_push(&kind, tag);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Keys;
fn keys() -> Keys {
Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
}
#[test]
fn missing_tag_returns_none() {
let event = EventBuilder::text_note("no-deadline")
.created_at(Timestamp::from_secs(1))
.sign_with_keys(&keys())
.unwrap();
assert_eq!(parse_expiration(&event).unwrap(), None);
assert!(!is_expired(&event, Timestamp::from_secs(u64::MAX)).unwrap());
}
#[test]
fn builder_attaches_expiration_tag() {
let deadline = Timestamp::from_secs(1_700_000_000);
let event = EventBuilder::text_note("deadline")
.created_at(Timestamp::from_secs(1))
.expiration(deadline)
.sign_with_keys(&keys())
.unwrap();
assert_eq!(parse_expiration(&event).unwrap(), Some(deadline));
}
#[test]
fn expiration_replaces_previous() {
let earlier = Timestamp::from_secs(100);
let later = Timestamp::from_secs(200);
let event = EventBuilder::text_note("replace")
.created_at(Timestamp::from_secs(1))
.expiration(earlier)
.expiration(later)
.sign_with_keys(&keys())
.unwrap();
assert_eq!(parse_expiration(&event).unwrap(), Some(later));
let count = event
.tags
.iter()
.filter(|t| t.kind() == TagKind::from_wire(EXPIRATION_TAG))
.count();
assert_eq!(count, 1);
}
#[test]
fn before_deadline_not_expired() {
let event = EventBuilder::text_note("future")
.created_at(Timestamp::from_secs(1))
.expiration(Timestamp::from_secs(2_000))
.sign_with_keys(&keys())
.unwrap();
assert!(!is_expired(&event, Timestamp::from_secs(1_999)).unwrap());
}
#[test]
fn at_or_after_deadline_is_expired() {
let event = EventBuilder::text_note("late")
.created_at(Timestamp::from_secs(1))
.expiration(Timestamp::from_secs(2_000))
.sign_with_keys(&keys())
.unwrap();
assert!(is_expired(&event, Timestamp::from_secs(2_000)).unwrap());
assert!(is_expired(&event, Timestamp::from_secs(2_001)).unwrap());
}
#[test]
fn malformed_value_is_reported() {
let event = EventBuilder::text_note("oops")
.created_at(Timestamp::from_secs(1))
.tag(Tag::new(["expiration", "soon"]).unwrap())
.sign_with_keys(&keys())
.unwrap();
let err = parse_expiration(&event).unwrap_err();
assert!(matches!(err, ExpirationError::InvalidTimestamp(_)));
}
#[test]
fn missing_value_is_reported() {
let event = EventBuilder::text_note("oops")
.created_at(Timestamp::from_secs(1))
.tag(Tag::new(["expiration"]).unwrap())
.sign_with_keys(&keys())
.unwrap();
let err = parse_expiration(&event).unwrap_err();
assert!(matches!(err, ExpirationError::MissingValue));
}
}