Skip to main content

nula_core/nips/
nip99.rs

1//! [NIP-99] Classified Listings.
2//!
3//! `kind: 30402` is the addressable event for arbitrary classified
4//! listings (goods, services, jobs, rentals, giveaways, …). The
5//! shape intentionally mirrors NIP-23 long-form content with extra
6//! structured metadata. `kind: 30403` is the inactive / draft sibling
7//! and uses the same schema so the same builder can author both.
8//!
9//! # Modelled fields
10//!
11//! - **Required**: `d` identifier + `.content` markdown body. The
12//!   spec marks `title`, `summary`, and `published_at` as
13//!   "SHOULD include"; we keep them optional so partially-populated
14//!   listings still round-trip.
15//! - **Pricing**: [`Price`] models the three-column `price` tag
16//!   (`amount`, `currency`, optional `frequency`). The amount stays a
17//!   `String` to preserve non-decimal representations apps may use
18//!   (e.g. very large integers without rounding).
19//! - **Location & geohash**: optional `location` + `g` tags.
20//! - **Status**: typed [`ListingStatus`] (`active` / `sold`) with a
21//!   forward-compatible `Custom(String)`.
22//! - **Hashtags**: `t` tags lower-cased automatically.
23//! - **Images**: NIP-58-shaped `image` tags via [`Image`] (URL +
24//!   optional `WxH` dimensions).
25//! - **References**: optional `e` and `a` tags.
26//!
27//! Unknown extras round-trip through [`Listing::extra_tags`].
28//!
29//! [NIP-99]: https://github.com/nostr-protocol/nips/blob/master/99.md
30
31use thiserror::Error;
32
33use crate::event::{
34    Alphabet, Coordinate, CoordinateError, Event, EventBuilder, EventId, EventIdError, Kind,
35    SingleLetterTag, Tag, TagKind, Tags,
36};
37use crate::types::{
38    ImageDimensions, ImageError, RelayUrl, RelayUrlError, Timestamp, TimestampError, Url, UrlError,
39};
40
41/// `kind: 30402` — classified listing.
42pub const KIND_CLASSIFIED_LISTING: Kind = Kind::CLASSIFIED_LISTING;
43
44/// `kind: 30403` — draft / inactive classified listing.
45pub const KIND_CLASSIFIED_LISTING_DRAFT: Kind = Kind::CLASSIFIED_LISTING_DRAFT;
46
47const TITLE_TAG: &str = "title";
48const SUMMARY_TAG: &str = "summary";
49const PUBLISHED_AT_TAG: &str = "published_at";
50const IMAGE_TAG: &str = "image";
51const LOCATION_TAG: &str = "location";
52const PRICE_TAG: &str = "price";
53const STATUS_TAG: &str = "status";
54
55/// Spec-defined wire tokens for the `status` tag (with a
56/// forward-compatible passthrough).
57#[derive(Debug, Clone, PartialEq, Eq, Hash)]
58pub enum ListingStatus {
59    /// `active`.
60    Active,
61    /// `sold`.
62    Sold,
63    /// Forward-compatible passthrough for unknown tokens.
64    Custom(String),
65}
66
67impl ListingStatus {
68    /// Wire token.
69    ///
70    /// Returns the spec-defined lowercase string or, for
71    /// [`Self::Custom`], the inner string slice. The borrow on the
72    /// inner string prevents this from being `const`.
73    #[must_use]
74    #[expect(
75        clippy::missing_const_for_fn,
76        reason = "`Self::Custom` borrows from a heap `String`"
77    )]
78    pub fn as_str(&self) -> &str {
79        match self {
80            Self::Active => "active",
81            Self::Sold => "sold",
82            Self::Custom(s) => s.as_str(),
83        }
84    }
85
86    /// Parse a wire token. Always succeeds: unknown tokens decode
87    /// as [`Self::Custom`].
88    #[must_use]
89    pub fn parse(token: &str) -> Self {
90        match token {
91            "active" => Self::Active,
92            "sold" => Self::Sold,
93            _ => Self::Custom(token.to_owned()),
94        }
95    }
96}
97
98/// Spec-defined recurrence noun for [`Price::frequency`]. Free-form
99/// per spec (`hour`, `day`, `week`, `month`, `year`, custom).
100pub type PriceFrequency = String;
101
102/// `price` tag bundle.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct Price {
105    /// Amount as a string so producers can pin large or
106    /// non-decimal representations verbatim.
107    pub amount: String,
108    /// ISO 4217 (or 4217-like) currency code (`USD`, `EUR`, `btc`).
109    pub currency: String,
110    /// Optional recurrence noun (`hour`, `day`, `week`, `month`,
111    /// `year`, custom).
112    pub frequency: Option<PriceFrequency>,
113}
114
115impl Price {
116    /// Construct a one-time price.
117    #[must_use]
118    pub fn new(amount: impl Into<String>, currency: impl Into<String>) -> Self {
119        Self {
120            amount: amount.into(),
121            currency: currency.into(),
122            frequency: None,
123        }
124    }
125
126    /// Attach a recurrence noun.
127    #[must_use]
128    pub fn frequency(mut self, frequency: impl Into<PriceFrequency>) -> Self {
129        self.frequency = Some(frequency.into());
130        self
131    }
132
133    /// Render as a [`Tag`].
134    #[must_use]
135    pub fn to_tag(&self) -> Tag {
136        let head = TagKind::from_wire(PRICE_TAG);
137        self.frequency.as_ref().map_or_else(
138            || Tag::with(&head, [self.amount.clone(), self.currency.clone()]),
139            |freq| {
140                Tag::with(
141                    &head,
142                    [self.amount.clone(), self.currency.clone(), freq.clone()],
143                )
144            },
145        )
146    }
147
148    /// Parse a `price` tag.
149    ///
150    /// # Errors
151    ///
152    /// - [`ListingError::WrongPriceTag`] when the head is not `price`.
153    /// - [`ListingError::MalformedPrice`] when the amount or
154    ///   currency columns are absent.
155    pub fn from_tag(tag: &Tag) -> Result<Self, ListingError> {
156        if tag.name() != PRICE_TAG {
157            return Err(ListingError::WrongPriceTag);
158        }
159        let amount = tag.get(1).ok_or(ListingError::MalformedPrice)?.to_owned();
160        let currency = tag.get(2).ok_or(ListingError::MalformedPrice)?.to_owned();
161        let frequency = tag.get(3).filter(|s| !s.is_empty()).map(str::to_owned);
162        Ok(Self {
163            amount,
164            currency,
165            frequency,
166        })
167    }
168}
169
170/// `image` tag bundle, optionally carrying a `WxH` dimension column
171/// (NIP-58 §"Badge Definition Event").
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct Image {
174    /// Image URL.
175    pub url: Url,
176    /// Optional dimensions (`WxH`).
177    pub dim: Option<ImageDimensions>,
178}
179
180impl Image {
181    /// Construct an image with no dimensions.
182    #[must_use]
183    pub const fn new(url: Url) -> Self {
184        Self { url, dim: None }
185    }
186
187    /// Attach pixel dimensions.
188    #[must_use]
189    pub const fn dim(mut self, dim: ImageDimensions) -> Self {
190        self.dim = Some(dim);
191        self
192    }
193
194    /// Render as a [`Tag`].
195    #[must_use]
196    pub fn to_tag(&self) -> Tag {
197        let head = TagKind::from_wire(IMAGE_TAG);
198        self.dim.map_or_else(
199            || Tag::with(&head, [self.url.as_str().to_owned()]),
200            |dim| Tag::with(&head, [self.url.as_str().to_owned(), dim.to_string()]),
201        )
202    }
203
204    /// Parse an `image` tag.
205    ///
206    /// # Errors
207    ///
208    /// - [`ListingError::WrongImageTag`] when the head is not `image`.
209    /// - [`ListingError::MalformedImage`] when the URL column is
210    ///   absent.
211    /// - URL / dim parser errors propagate.
212    pub fn from_tag(tag: &Tag) -> Result<Self, ListingError> {
213        if tag.name() != IMAGE_TAG {
214            return Err(ListingError::WrongImageTag);
215        }
216        let url_str = tag.get(1).ok_or(ListingError::MalformedImage)?;
217        let url = Url::parse(url_str)?;
218        let dim = match tag.get(2) {
219            Some(d) if !d.is_empty() => Some(d.parse::<ImageDimensions>()?),
220            _ => None,
221        };
222        Ok(Self { url, dim })
223    }
224}
225
226/// Typed bundle for a NIP-99 listing event.
227#[derive(Debug, Clone, PartialEq, Eq, Default)]
228pub struct Listing {
229    /// `d`-tag identifier.
230    pub identifier: String,
231    /// Markdown body — `.content`.
232    pub content: String,
233    /// `title` tag.
234    pub title: Option<String>,
235    /// `summary` tag.
236    pub summary: Option<String>,
237    /// `published_at` tag.
238    pub published_at: Option<Timestamp>,
239    /// `location` tag.
240    pub location: Option<String>,
241    /// `g` geohash tag.
242    pub geohash: Option<String>,
243    /// `price` bundle.
244    pub price: Option<Price>,
245    /// `status` token.
246    pub status: Option<ListingStatus>,
247    /// `t` hashtags (lower-cased per NIP-24).
248    pub hashtags: Vec<String>,
249    /// `image` tags.
250    pub images: Vec<Image>,
251    /// `e` references with optional relay hint.
252    pub event_refs: Vec<EventReference>,
253    /// `a` references with optional relay hint.
254    pub address_refs: Vec<AddressReference>,
255    /// Forward-compatible passthrough for unknown tags.
256    pub extra_tags: Vec<Tag>,
257}
258
259/// `e` reference tag (event id + optional relay hint).
260#[derive(Debug, Clone, PartialEq, Eq)]
261pub struct EventReference {
262    /// Referenced event id.
263    pub id: EventId,
264    /// Optional relay hint.
265    pub relay_hint: Option<RelayUrl>,
266}
267
268/// `a` reference tag (coordinate + optional relay hint).
269#[derive(Debug, Clone, PartialEq, Eq)]
270pub struct AddressReference {
271    /// Referenced addressable coordinate.
272    pub coordinate: Coordinate,
273    /// Optional relay hint.
274    pub relay_hint: Option<RelayUrl>,
275}
276
277impl Listing {
278    /// Construct an empty listing seeded with `identifier`.
279    #[must_use]
280    pub fn new(identifier: impl Into<String>) -> Self {
281        Self {
282            identifier: identifier.into(),
283            ..Self::default()
284        }
285    }
286
287    /// Set the markdown body.
288    #[must_use]
289    pub fn content(mut self, content: impl Into<String>) -> Self {
290        self.content = content.into();
291        self
292    }
293
294    /// Set [`Self::title`].
295    #[must_use]
296    pub fn title(mut self, title: impl Into<String>) -> Self {
297        self.title = Some(title.into());
298        self
299    }
300
301    /// Set [`Self::summary`].
302    #[must_use]
303    pub fn summary(mut self, summary: impl Into<String>) -> Self {
304        self.summary = Some(summary.into());
305        self
306    }
307
308    /// Set [`Self::published_at`].
309    #[must_use]
310    pub const fn published_at(mut self, published_at: Timestamp) -> Self {
311        self.published_at = Some(published_at);
312        self
313    }
314
315    /// Set [`Self::location`].
316    #[must_use]
317    pub fn location(mut self, location: impl Into<String>) -> Self {
318        self.location = Some(location.into());
319        self
320    }
321
322    /// Set [`Self::geohash`].
323    #[must_use]
324    pub fn geohash(mut self, geohash: impl Into<String>) -> Self {
325        self.geohash = Some(geohash.into());
326        self
327    }
328
329    /// Set [`Self::price`].
330    #[must_use]
331    pub fn price(mut self, price: Price) -> Self {
332        self.price = Some(price);
333        self
334    }
335
336    /// Set [`Self::status`].
337    #[must_use]
338    pub fn status(mut self, status: ListingStatus) -> Self {
339        self.status = Some(status);
340        self
341    }
342
343    /// Append a hashtag (auto lower-cased).
344    #[must_use]
345    pub fn hashtag(mut self, hashtag: impl AsRef<str>) -> Self {
346        self.hashtags.push(hashtag.as_ref().to_lowercase());
347        self
348    }
349
350    /// Append an image.
351    #[must_use]
352    pub fn image(mut self, image: Image) -> Self {
353        self.images.push(image);
354        self
355    }
356
357    /// Append an `e` reference.
358    #[must_use]
359    pub fn event_ref(mut self, reference: EventReference) -> Self {
360        self.event_refs.push(reference);
361        self
362    }
363
364    /// Append an `a` reference.
365    #[must_use]
366    pub fn address_ref(mut self, reference: AddressReference) -> Self {
367        self.address_refs.push(reference);
368        self
369    }
370
371    /// Build the listing's addressable coordinate.
372    #[must_use]
373    pub fn coordinate(&self, author: crate::PublicKey, kind: Kind) -> Coordinate {
374        Coordinate::new(kind, author, self.identifier.clone())
375    }
376
377    /// Parse a `kind: 30402` or `kind: 30403` event back into a
378    /// typed bundle.
379    ///
380    /// # Errors
381    ///
382    /// - [`ListingError::WrongKind`] for any other kind.
383    /// - [`ListingError::MissingIdentifier`] when the `d` tag is
384    ///   absent.
385    /// - Field-specific errors for malformed columns.
386    pub fn from_event(event: &Event) -> Result<Self, ListingError> {
387        if event.kind != KIND_CLASSIFIED_LISTING && event.kind != KIND_CLASSIFIED_LISTING_DRAFT {
388            return Err(ListingError::WrongKind(event.kind));
389        }
390        let identifier = d_value(&event.tags)
391            .ok_or(ListingError::MissingIdentifier)?
392            .to_owned();
393        let mut listing = Self {
394            identifier,
395            content: event.content.clone(),
396            ..Self::default()
397        };
398        for tag in &event.tags {
399            absorb_tag(tag, &mut listing)?;
400        }
401        Ok(listing)
402    }
403}
404
405fn absorb_tag(tag: &Tag, listing: &mut Listing) -> Result<(), ListingError> {
406    match tag.kind() {
407        TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::D => {}
408        TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::T => {
409            if let Some(t) = tag.get(1) {
410                listing.hashtags.push(t.to_owned());
411            }
412        }
413        TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::G => {
414            listing.geohash = tag.get(1).map(str::to_owned);
415        }
416        TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::E => {
417            listing.event_refs.push(parse_event_ref(tag)?);
418        }
419        TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::A => {
420            listing.address_refs.push(parse_address_ref(tag)?);
421        }
422        _ if tag.name() == TITLE_TAG => listing.title = tag.get(1).map(str::to_owned),
423        _ if tag.name() == SUMMARY_TAG => listing.summary = tag.get(1).map(str::to_owned),
424        _ if tag.name() == PUBLISHED_AT_TAG => {
425            if let Some(raw) = tag.get(1) {
426                listing.published_at = Some(raw.parse::<Timestamp>()?);
427            }
428        }
429        _ if tag.name() == LOCATION_TAG => listing.location = tag.get(1).map(str::to_owned),
430        _ if tag.name() == STATUS_TAG => {
431            listing.status = tag.get(1).map(ListingStatus::parse);
432        }
433        _ if tag.name() == PRICE_TAG => listing.price = Some(Price::from_tag(tag)?),
434        _ if tag.name() == IMAGE_TAG => listing.images.push(Image::from_tag(tag)?),
435        _ => listing.extra_tags.push(tag.clone()),
436    }
437    Ok(())
438}
439
440fn parse_event_ref(tag: &Tag) -> Result<EventReference, ListingError> {
441    let id_hex = tag.get(1).ok_or(ListingError::MalformedEventRef)?;
442    let id = EventId::parse(id_hex)?;
443    let relay_hint = match tag.get(2) {
444        Some(s) if !s.is_empty() => Some(RelayUrl::parse(s)?),
445        _ => None,
446    };
447    Ok(EventReference { id, relay_hint })
448}
449
450fn parse_address_ref(tag: &Tag) -> Result<AddressReference, ListingError> {
451    let coord_str = tag.get(1).ok_or(ListingError::MalformedAddressRef)?;
452    let coordinate = Coordinate::parse(coord_str)?;
453    let relay_hint = match tag.get(2) {
454        Some(s) if !s.is_empty() => Some(RelayUrl::parse(s)?),
455        _ => None,
456    };
457    Ok(AddressReference {
458        coordinate,
459        relay_hint,
460    })
461}
462
463fn d_value(tags: &Tags) -> Option<&str> {
464    let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::D));
465    tags.find_first(&head).and_then(|tag| tag.get(1))
466}
467
468/// Errors raised by NIP-99 parsers.
469#[derive(Debug, Error)]
470#[non_exhaustive]
471pub enum ListingError {
472    /// The event was neither `kind: 30402` nor `kind: 30403`.
473    #[error("expected kind 30402 or 30403 (classified listing), got kind {}", .0.as_u16())]
474    WrongKind(Kind),
475    /// `d` tag is absent.
476    #[error("NIP-99 listing missing `d` tag")]
477    MissingIdentifier,
478    /// `price` tag head was not `price`.
479    #[error("expected `price` tag")]
480    WrongPriceTag,
481    /// `price` tag is missing the amount or currency column.
482    #[error("`price` tag missing amount or currency")]
483    MalformedPrice,
484    /// `image` tag head was not `image`.
485    #[error("expected `image` tag")]
486    WrongImageTag,
487    /// `image` tag is missing the URL column.
488    #[error("`image` tag missing URL")]
489    MalformedImage,
490    /// `e` reference tag is missing the event id column.
491    #[error("`e` reference tag missing event id")]
492    MalformedEventRef,
493    /// `a` reference tag is missing the coordinate column.
494    #[error("`a` reference tag missing coordinate")]
495    MalformedAddressRef,
496    /// Wrapped event-id parser error.
497    #[error(transparent)]
498    InvalidEventId(#[from] EventIdError),
499    /// Wrapped coordinate parser error.
500    #[error(transparent)]
501    InvalidCoordinate(#[from] CoordinateError),
502    /// Wrapped relay-url parser error.
503    #[error(transparent)]
504    InvalidRelayUrl(#[from] RelayUrlError),
505    /// Wrapped URL parser error.
506    #[error(transparent)]
507    InvalidUrl(#[from] UrlError),
508    /// Wrapped image-dim parser error.
509    #[error(transparent)]
510    InvalidDim(#[from] ImageError),
511    /// Wrapped `published_at` timestamp parser error.
512    #[error(transparent)]
513    InvalidTimestamp(#[from] TimestampError),
514}
515
516impl EventBuilder {
517    /// Author a NIP-99 listing event of `kind`.
518    ///
519    /// Use [`KIND_CLASSIFIED_LISTING`] for active listings or
520    /// [`KIND_CLASSIFIED_LISTING_DRAFT`] for drafts; both share the
521    /// same schema.
522    #[must_use]
523    pub fn classified_listing(listing: &Listing, kind: Kind) -> Self {
524        let mut builder = Self::new(kind, listing.content.clone());
525        builder = builder.tag(Tag::d(&listing.identifier));
526        if let Some(title) = &listing.title {
527            builder = builder.tag(Tag::with(&TagKind::from_wire(TITLE_TAG), [title.clone()]));
528        }
529        if let Some(summary) = &listing.summary {
530            builder = builder.tag(Tag::with(
531                &TagKind::from_wire(SUMMARY_TAG),
532                [summary.clone()],
533            ));
534        }
535        if let Some(ts) = listing.published_at {
536            builder = builder.tag(Tag::with(
537                &TagKind::from_wire(PUBLISHED_AT_TAG),
538                [ts.as_secs().to_string()],
539            ));
540        }
541        for hashtag in &listing.hashtags {
542            builder = builder.tag(Tag::t(hashtag));
543        }
544        for image in &listing.images {
545            builder = builder.tag(image.to_tag());
546        }
547        if let Some(location) = &listing.location {
548            builder = builder.tag(Tag::with(
549                &TagKind::from_wire(LOCATION_TAG),
550                [location.clone()],
551            ));
552        }
553        if let Some(geohash) = &listing.geohash {
554            let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::G));
555            builder = builder.tag(Tag::with(&head, [geohash.clone()]));
556        }
557        if let Some(price) = &listing.price {
558            builder = builder.tag(price.to_tag());
559        }
560        if let Some(status) = &listing.status {
561            builder = builder.tag(Tag::with(
562                &TagKind::from_wire(STATUS_TAG),
563                [status.as_str().to_owned()],
564            ));
565        }
566        for r in &listing.event_refs {
567            let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E));
568            builder = builder.tag(r.relay_hint.as_ref().map_or_else(
569                || Tag::with(&head, [r.id.to_hex()]),
570                |relay| Tag::with(&head, [r.id.to_hex(), relay.as_str().to_owned()]),
571            ));
572        }
573        for r in &listing.address_refs {
574            builder = builder.tag(r.relay_hint.as_ref().map_or_else(
575                || Tag::a(&r.coordinate),
576                |relay| Tag::a_with_relay(&r.coordinate, relay),
577            ));
578        }
579        for tag in &listing.extra_tags {
580            builder = builder.tag(tag.clone());
581        }
582        builder
583    }
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589    use crate::Keys;
590
591    fn keys() -> Keys {
592        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
593    }
594
595    #[test]
596    fn round_trip_minimal_listing() {
597        let listing = Listing::new("lorem-ipsum").content("**markdown**");
598        let event = EventBuilder::classified_listing(&listing, KIND_CLASSIFIED_LISTING)
599            .sign_with_keys(&keys())
600            .unwrap();
601        let parsed = Listing::from_event(&event).unwrap();
602        assert_eq!(parsed, listing);
603    }
604
605    #[test]
606    fn round_trip_full_listing() {
607        let listing = Listing::new("lorem-ipsum")
608            .content("Lorem ipsum body.")
609            .title("Lorem Ipsum")
610            .summary("Brief")
611            .published_at(Timestamp::from_secs(1_296_962_229))
612            .location("NYC")
613            .geohash("dr5regw3p")
614            .price(Price::new("100", "USD"))
615            .status(ListingStatus::Active)
616            .hashtag("ELECTRONICS")
617            .image(
618                Image::new(Url::parse("https://example.com/p.jpg").unwrap())
619                    .dim("256x256".parse().unwrap()),
620            )
621            .event_ref(EventReference {
622                id: EventId::from_byte_array([0x7f; 32]),
623                relay_hint: Some(RelayUrl::parse("wss://relay.example/").unwrap()),
624            })
625            .address_ref(AddressReference {
626                coordinate: Coordinate::new(
627                    Kind::new(30_023),
628                    *keys().public_key(),
629                    "post".to_owned(),
630                ),
631                relay_hint: Some(RelayUrl::parse("wss://relay.nostr/").unwrap()),
632            });
633        let event = EventBuilder::classified_listing(&listing, KIND_CLASSIFIED_LISTING)
634            .sign_with_keys(&keys())
635            .unwrap();
636        let parsed = Listing::from_event(&event).unwrap();
637        // Hashtag should be lower-cased.
638        assert_eq!(parsed.hashtags, vec!["electronics".to_owned()]);
639        let expected = Listing {
640            hashtags: vec!["electronics".to_owned()],
641            ..listing
642        };
643        assert_eq!(parsed, expected);
644    }
645
646    #[test]
647    fn round_trip_draft() {
648        let listing = Listing::new("draft-1").content("hidden");
649        let event = EventBuilder::classified_listing(&listing, KIND_CLASSIFIED_LISTING_DRAFT)
650            .sign_with_keys(&keys())
651            .unwrap();
652        let parsed = Listing::from_event(&event).unwrap();
653        assert_eq!(parsed, listing);
654        assert_eq!(event.kind, KIND_CLASSIFIED_LISTING_DRAFT);
655    }
656
657    #[test]
658    fn price_with_frequency_round_trips() {
659        let price = Price::new("15", "EUR").frequency("month");
660        let tag = price.to_tag();
661        let parsed = Price::from_tag(&tag).unwrap();
662        assert_eq!(parsed, price);
663    }
664
665    #[test]
666    fn status_parses_unknown_tokens_as_custom() {
667        let listing = Listing::new("status-test")
668            .content("…")
669            .status(ListingStatus::Custom("expired".into()));
670        let event = EventBuilder::classified_listing(&listing, KIND_CLASSIFIED_LISTING)
671            .sign_with_keys(&keys())
672            .unwrap();
673        let parsed = Listing::from_event(&event).unwrap();
674        assert_eq!(parsed.status, Some(ListingStatus::Custom("expired".into())));
675    }
676
677    #[test]
678    fn wrong_kind_is_rejected() {
679        let event = EventBuilder::text_note("nope")
680            .sign_with_keys(&keys())
681            .unwrap();
682        assert!(matches!(
683            Listing::from_event(&event),
684            Err(ListingError::WrongKind(_))
685        ));
686    }
687
688    #[test]
689    fn missing_identifier_is_rejected() {
690        let event = EventBuilder::new(KIND_CLASSIFIED_LISTING, "")
691            .sign_with_keys(&keys())
692            .unwrap();
693        assert!(matches!(
694            Listing::from_event(&event),
695            Err(ListingError::MissingIdentifier)
696        ));
697    }
698
699    #[test]
700    fn malformed_price_is_rejected() {
701        let event = EventBuilder::new(KIND_CLASSIFIED_LISTING, "")
702            .tag(Tag::d("listing-1"))
703            .tag(Tag::with(&TagKind::from_wire(PRICE_TAG), ["100"]))
704            .sign_with_keys(&keys())
705            .unwrap();
706        assert!(matches!(
707            Listing::from_event(&event),
708            Err(ListingError::MalformedPrice)
709        ));
710    }
711
712    #[test]
713    fn extra_tags_are_preserved() {
714        let custom = Tag::with(&TagKind::Custom("note".to_owned()), ["preserve me"]);
715        let listing = Listing::new("listing-x").content("body");
716        let mut builder = EventBuilder::classified_listing(&listing, KIND_CLASSIFIED_LISTING);
717        builder = builder.tag(custom.clone());
718        let event = builder.sign_with_keys(&keys()).unwrap();
719        let parsed = Listing::from_event(&event).unwrap();
720        assert_eq!(parsed.extra_tags, vec![custom]);
721    }
722}