lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 9ef5c5a3e1a12fb6e3989aa53c669608b9c2fd9f
parent 7e99f9c93f937324564b31c4f38d1f15afca4545
Author: triesap <tyson@radroots.org>
Date:   Sun, 19 Jul 2026 02:03:25 +0000

refactor: separate classified and operational listings

- reserve generic kind-30402 taxonomy and coordinates for Classified Listing
- rename rich farm and inventory event authority to Operational Listing
- migrate non-client consumers while preserving stable wire and persistence fields
- govern generated roots, contracts, release metadata, and retired-alias hygiene

Diffstat:
MCHANGELOG.md | 26+++++++++++++++++++++-----
Dcontracts/conformance/vectors/events/listing_tags_full.v1.json | 27---------------------------
Acontracts/conformance/vectors/events/operational_listing_tags_full.v1.json | 27+++++++++++++++++++++++++++
Mcontracts/conformance/vectors/knowledge/manifest_and_decode.v1.json | 4++--
Dcontracts/conformance/vectors/listing/build_draft.v1.json | 33---------------------------------
Dcontracts/conformance/vectors/listing/build_tags.v1.json | 29-----------------------------
Dcontracts/conformance/vectors/listing/parse_event.v1.json | 215-------------------------------------------------------------------------------
Acontracts/conformance/vectors/operational_listing/build_draft.v1.json | 33+++++++++++++++++++++++++++++++++
Acontracts/conformance/vectors/operational_listing/build_tags.v1.json | 29+++++++++++++++++++++++++++++
Acontracts/conformance/vectors/operational_listing/parse_event.v1.json | 215+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/conformance/vectors/social/upgraded_boundaries.v1.json | 26+++++++++++++-------------
Acontracts/conformance/vectors/trade/parse_classified_listing_address.v1.json | 16++++++++++++++++
Dcontracts/conformance/vectors/trade/parse_listing_address.v1.json | 16----------------
Dcontracts/conformance/vectors/trade_validation/validate_listing_event.v1.json | 20--------------------
Acontracts/conformance/vectors/trade_validation/validate_operational_listing_event.v1.json | 20++++++++++++++++++++
Mcontracts/event_boundary_matrix.md | 12+++++++++++-
Mcontracts/events/social-events.md | 35+++++++++++++++++++----------------
Mcontracts/knowledge/knowledge_event_contract_manifest.v2.json | 2+-
Mcontracts/knowledge/knowledge_event_contract_manifest.v2.sha256 | 2+-
Mcontracts/operations.toml | 94++++++++++++++++++++++++++++++++++++++++----------------------------------------
Mcontracts/releases/1.0.0-alpha.1.toml | 20+++++++++++++++++++-
Mcrates/authority/src/actor.rs | 7+++++--
Mcrates/authority/src/authorization.rs | 118+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mcrates/authority/src/error.rs | 52++++++++++++++++++++++++++++++++--------------------
Mcrates/event/src/contract.rs | 249++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mcrates/event/src/dto.rs | 7++++---
Mcrates/event/src/generated/dto_roots.rs | 40++++++++++++++++++++++++++++------------
Mcrates/event/src/ids.rs | 82++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mcrates/event/src/kinds.rs | 60+++++++++++++++++++++++++++++++-----------------------------
Mcrates/event/src/lib.rs | 2+-
Dcrates/event/src/listing.rs | 269-------------------------------------------------------------------------------
Acrates/event/src/operational_listing.rs | 305++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event/src/order.rs | 129+++++++++++++++++++++++++++++++------------------------------------------------
Mcrates/event/src/trade.rs | 15++++++++++++---
Mcrates/event/src/trade_validation.rs | 32++++++++++++++++++++------------
Mcrates/event_codec/src/farm/list_sets.rs | 30++++++++++++++++--------------
Mcrates/event_codec/src/farm/mod.rs | 15+++++++++------
Mcrates/event_codec/src/lib.rs | 2+-
Dcrates/event_codec/src/listing/decode.rs | 848-------------------------------------------------------------------------------
Dcrates/event_codec/src/listing/encode.rs | 163-------------------------------------------------------------------------------
Dcrates/event_codec/src/listing/tags.rs | 1322-------------------------------------------------------------------------------
Mcrates/event_codec/src/manifest.rs | 34++++++++++++++++++++++++++++++----
Acrates/event_codec/src/operational_listing/decode.rs | 894+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/event_codec/src/operational_listing/encode.rs | 151++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rcrates/event_codec/src/listing/mod.rs -> crates/event_codec/src/operational_listing/mod.rs | 0
Acrates/event_codec/src/operational_listing/tags.rs | 1363+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event_codec/src/order/decode.rs | 16+++++++++-------
Mcrates/event_codec/src/order/tags.rs | 14+++++++-------
Mcrates/event_codec/src/tag_builders.rs | 14+++++++-------
Mcrates/event_codec/src/trade/mod.rs | 6+++---
Mcrates/event_codec/tests/domain_encode_non_serde.rs | 170+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Dcrates/event_codec/tests/fixtures/listing_parse_event.v1.json | 215-------------------------------------------------------------------------------
Acrates/event_codec/tests/fixtures/operational_listing_parse_event.v1.json | 215+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dcrates/event_codec/tests/listing.rs | 1524-------------------------------------------------------------------------------
Acrates/event_codec/tests/operational_listing.rs | 1530+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event_codec/tests/social_events.rs | 18+++++++++---------
Mcrates/event_codec/tests/structured_encode_default.rs | 25++++++++++++++-----------
Mcrates/event_codec/tests/tag_builders.rs | 113+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mcrates/event_store/src/model.rs | 14++++++++++----
Mcrates/event_store/src/store.rs | 55++++++++++++++++++++++++++++++++++++++++---------------
Mcrates/outbox/src/store.rs | 8++++----
Mcrates/protocol_contract_v1/src/lib.rs | 22++++++++++++++++++++--
Mcrates/replica_sync/src/ingest.rs | 63+++++++++++++++++++++++++++++++++++++--------------------------
Mcrates/trade/src/dto.rs | 94+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mcrates/trade/src/identity.rs | 18++++++++++--------
Mcrates/trade/src/lib.rs | 2+-
Dcrates/trade/src/listing/draft.rs | 481-------------------------------------------------------------------------------
Dcrates/trade/src/listing/mod.rs | 290-------------------------------------------------------------------------------
Dcrates/trade/src/listing/model.rs | 17-----------------
Dcrates/trade/src/listing/mutation.rs | 453-------------------------------------------------------------------------------
Dcrates/trade/src/listing/price_ext.rs | 215-------------------------------------------------------------------------------
Dcrates/trade/src/listing/validation.rs | 706-------------------------------------------------------------------------------
Acrates/trade/src/operational_listing/draft.rs | 517+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/trade/src/operational_listing/mod.rs | 251+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/trade/src/operational_listing/model.rs | 17+++++++++++++++++
Acrates/trade/src/operational_listing/mutation.rs | 502+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/trade/src/operational_listing/price_ext.rs | 217+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/trade/src/operational_listing/validation.rs | 732+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/trade/src/order.rs | 589+++++++++++++++++++++++++++++++++++++------------------------------------------
Mcrates/trade/src/prelude.rs | 2+-
Mcrates/trade/src/projection.rs | 123++++++++++++++++++++++++++++++++++++++++---------------------------------------
Mcrates/trade/src/workflow.rs | 4++--
Mcrates/trade_sp1_guest/src/lib.rs | 8++++----
Mcrates/trade_sp1_host/src/lib.rs | 4++--
Mdto_bindgen.toml | 2+-
Mtools/xtask/src/contract.rs | 54+++++++++++++++++++++++++++---------------------------
Mtools/xtask/src/hygiene.rs | 246++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
87 files changed, 8772 insertions(+), 7884 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md @@ -9,10 +9,18 @@ publish policy both pass for the same source revision. ### Changed +- NIP-99 kind `30402` now has an explicit two-level taxonomy: the standard + protocol kind and coordinate are **Classified Listing**, while the richer + Radroots farm, bin, inventory, and price profile is **Operational Listing**. + Public constants, types, functions, modules, operation IDs, and generated DTO + roots use those unambiguous names with no legacy `listing` aliases or modules. - Operational listing decoding now has one tag-authoritative implementation in `radroots_event_codec`. The typed parts decoder reports the established listing error taxonomy, and JSON content can no longer override canonical product, inventory, or bin tags in trade and replica consumers. +- Operational listing authoring now emits canonical Markdown content from the + tag-authoritative model. Tolerant inbound JSON inspection remains a decode + compatibility boundary only and is not an authoring format. - Generic NIP-01 identifier and signature verification is now independent of knowledge decoding, and every dynamic Nostr kind conversion rejects values above `65535` instead of truncating them. Canonical-length author keys that @@ -34,7 +42,7 @@ publish policy both pass for the same source revision. access; externally supplied unsigned events, NIP-46 signing, and signed-event relay remain explicit low-level Nostr interoperability with no typed product authoring claim. -- Frozen drafts now carry event-contract registry version `2`, revalidate all +- Frozen drafts now carry event-contract registry version `3`, revalidate all persisted fields and the recomputed event id during deserialization and signing, and enforce explicit `GenericDraft`, `TypedOnly`, and `ReadOnly` authoring policies. @@ -71,6 +79,11 @@ publish policy both pass for the same source revision. - The duplicate private operational listing parser in `radroots_trade` was removed; trade validation now consumes the canonical event codec. +- The ambiguous `listing` source modules and public compatibility aliases were + removed. Consumers must migrate to the Classified Listing protocol names or + Operational Listing product names according to the API's responsibility. +- The public operational-listing JSON wire-parts authoring helper was removed; + product authoring uses the canonical Markdown wire-parts path. - Legacy calendar event models and permissive calendar tag-builder authoring paths were removed. - Direct `RadrootsProfile` draft encoding and the identity profile publisher were @@ -104,10 +117,13 @@ publish policy both pass for the same source revision. compatibility version instead of package SemVer. Existing backups stamped by `0.1.0-alpha.2` remain restorable because this release does not change their stored schema. -- Persisted frozen drafts with event-contract registry version `1` are rejected - and must be reconstructed against registry version `2`; strict Profile plus - typed and read-only post contracts can no longer be reconstructed as generic - drafts. +- Persisted frozen drafts with event-contract registry versions `1` or `2` are + rejected and must be reconstructed against registry version `3`; strict + Profile plus typed and read-only post contracts can no longer be + reconstructed as generic drafts. +- This breaking capsule revision must not be pinned or published through + downstream product clients until `radroots_app_rt` is migrated, generated FFI + bindings are rebuilt, and downstream compile and contract qualification pass. - `radroots_event_from_nostr` now returns `Result<RadrootsEventEnvelope, RadrootsEventEnvelopeError>`; callers must handle hostile or oversized SDK events explicitly. diff --git a/contracts/conformance/vectors/events/listing_tags_full.v1.json b/contracts/conformance/vectors/events/listing_tags_full.v1.json @@ -1,27 +0,0 @@ -{ - "suite": "events", - "contract_version": "1.0.0", - "vectors": [ - { - "id": "listing_tags_full_minimal_001", - "kind": "listing_tags_full", - "input": { - "d_tag": "listing-001", - "farm": { - "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "d_tag": "farm-001" - }, - "product": { - "key": "coffee.green", - "title": "green coffee lot", - "category": "coffee" - }, - "primary_bin_id": "bin-a", - "bins": [] - }, - "expected": { - "tags": [] - } - } - ] -} diff --git a/contracts/conformance/vectors/events/operational_listing_tags_full.v1.json b/contracts/conformance/vectors/events/operational_listing_tags_full.v1.json @@ -0,0 +1,27 @@ +{ + "suite": "events", + "contract_version": "1.0.0", + "vectors": [ + { + "id": "operational_listing_tags_full_minimal_001", + "kind": "operational_listing_tags_full", + "input": { + "d_tag": "listing-001", + "farm": { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "d_tag": "farm-001" + }, + "product": { + "key": "coffee.green", + "title": "green coffee lot", + "category": "coffee" + }, + "primary_bin_id": "bin-a", + "bins": [] + }, + "expected": { + "tags": [] + } + } + ] +} diff --git a/contracts/conformance/vectors/knowledge/manifest_and_decode.v1.json b/contracts/conformance/vectors/knowledge/manifest_and_decode.v1.json @@ -6,11 +6,11 @@ "id": "knowledge_manifest_fields_valid_001", "kind": "knowledge.contract_manifest_json.valid", "input": { - "registry": "radroots_event_contract_registry_v2" + "registry": "radroots_event_contract_registry_v3" }, "expected": { "schema_version": 2, - "registry_version": 2, + "registry_version": 3, "required_fields": [ "schema_version", "registry_version", diff --git a/contracts/conformance/vectors/listing/build_draft.v1.json b/contracts/conformance/vectors/listing/build_draft.v1.json @@ -1,33 +0,0 @@ -{ - "suite": "listing", - "contract_version": "1.0.0", - "vectors": [ - { - "id": "listing_build_draft_minimal_001", - "kind": "listing.build_draft", - "input": { - "listing": { - "d_tag": "AAAAAAAAAAAAAAAAAAAAAg", - "farm": { - "pubkey": "farm_pubkey", - "d_tag": "AAAAAAAAAAAAAAAAAAAAAA" - }, - "product": { - "key": "sku", - "title": "widget", - "category": "tools" - }, - "primary_bin_id": "bin-1", - "bins": [] - } - }, - "expected": { - "wire_parts": { - "kind": "listing", - "content_shape": "listing_markdown_or_empty", - "tags_shape": "listing_tags_full" - } - } - } - ] -} diff --git a/contracts/conformance/vectors/listing/build_tags.v1.json b/contracts/conformance/vectors/listing/build_tags.v1.json @@ -1,29 +0,0 @@ -{ - "suite": "listing", - "contract_version": "1.0.0", - "vectors": [ - { - "id": "listing_build_tags_minimal_001", - "kind": "listing.build_tags", - "input": { - "listing": { - "d_tag": "AAAAAAAAAAAAAAAAAAAAAg", - "farm": { - "pubkey": "farm_pubkey", - "d_tag": "AAAAAAAAAAAAAAAAAAAAAA" - }, - "product": { - "key": "sku", - "title": "widget", - "category": "tools" - }, - "primary_bin_id": "bin-1", - "bins": [] - } - }, - "expected": { - "tags_shape": "listing_tags" - } - } - ] -} diff --git a/contracts/conformance/vectors/listing/parse_event.v1.json b/contracts/conformance/vectors/listing/parse_event.v1.json @@ -1,215 +0,0 @@ -{ - "suite": "operational_listing_parse_event", - "contract_version": "1.0.0", - "vectors": [ - { - "id": "operational_listing_tags_override_json_content_001", - "kind": "listing.parse_event.valid", - "input": { - "event_kind": 30402, - "content": "{\"product\":{\"title\":\"Content override\",\"category\":\"Content category\"},\"inventory_available\":\"99\"}", - "tags": [ - ["d", "AAAAAAAAAAAAAAAAAAAAAg"], - ["p", "farm_pubkey"], - ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], - ["key", "sku"], - ["title", "Widget"], - ["category", "Tools"], - ["radroots:primary_bin", "bin-1"], - ["radroots:bin", "bin-1", "1", "each"], - ["radroots:price", "bin-1", "10", "USD", "1", "each"], - ["price", "10", "USD"] - ] - }, - "expected": { - "listing": { - "d_tag": "AAAAAAAAAAAAAAAAAAAAAg", - "title": "Widget", - "category": "Tools", - "primary_bin_id": "bin-1", - "quantity_amount": "1", - "quantity_unit": "each", - "price_amount": "10", - "price_currency": "USD", - "inventory_available": null - } - } - }, - { - "id": "operational_listing_missing_d_001", - "kind": "listing.parse_event.invalid", - "input": { - "event_kind": 30402, - "content": "# Widget", - "tags": [ - ["p", "farm_pubkey"], - ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], - ["key", "sku"], - ["title", "Widget"], - ["category", "Tools"], - ["radroots:primary_bin", "bin-1"], - ["radroots:bin", "bin-1", "1", "each"], - ["radroots:price", "bin-1", "10", "USD", "1", "each"] - ] - }, - "expected": { - "error": {"MissingTag": "d"} - } - }, - { - "id": "operational_listing_invalid_d_001", - "kind": "listing.parse_event.invalid", - "input": { - "event_kind": 30402, - "content": "# Widget", - "tags": [ - ["d", "bad d"], - ["p", "farm_pubkey"], - ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], - ["key", "sku"], - ["title", "Widget"], - ["category", "Tools"], - ["radroots:primary_bin", "bin-1"], - ["radroots:bin", "bin-1", "1", "each"], - ["radroots:price", "bin-1", "10", "USD", "1", "each"] - ] - }, - "expected": { - "error": {"InvalidTag": "d"} - } - }, - { - "id": "operational_listing_invalid_number_001", - "kind": "listing.parse_event.invalid", - "input": { - "event_kind": 30402, - "content": "# Widget", - "tags": [ - ["d", "AAAAAAAAAAAAAAAAAAAAAg"], - ["p", "farm_pubkey"], - ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], - ["key", "sku"], - ["title", "Widget"], - ["category", "Tools"], - ["radroots:primary_bin", "bin-1"], - ["radroots:bin", "bin-1", "bad", "each"], - ["radroots:price", "bin-1", "10", "USD", "1", "each"] - ] - }, - "expected": { - "error": {"InvalidNumber": "radroots:bin"} - } - }, - { - "id": "operational_listing_invalid_unit_001", - "kind": "listing.parse_event.invalid", - "input": { - "event_kind": 30402, - "content": "# Widget", - "tags": [ - ["d", "AAAAAAAAAAAAAAAAAAAAAg"], - ["p", "farm_pubkey"], - ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], - ["key", "sku"], - ["title", "Widget"], - ["category", "Tools"], - ["radroots:primary_bin", "bin-1"], - ["radroots:bin", "bin-1", "1", "bad"], - ["radroots:price", "bin-1", "10", "USD", "1", "each"] - ] - }, - "expected": { - "error": "InvalidUnit" - } - }, - { - "id": "operational_listing_invalid_currency_001", - "kind": "listing.parse_event.invalid", - "input": { - "event_kind": 30402, - "content": "# Widget", - "tags": [ - ["d", "AAAAAAAAAAAAAAAAAAAAAg"], - ["p", "farm_pubkey"], - ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], - ["key", "sku"], - ["title", "Widget"], - ["category", "Tools"], - ["radroots:primary_bin", "bin-1"], - ["radroots:bin", "bin-1", "1", "each"], - ["radroots:price", "bin-1", "10", "US", "1", "each"] - ] - }, - "expected": { - "error": "InvalidCurrency" - } - }, - { - "id": "operational_listing_invalid_discount_001", - "kind": "listing.parse_event.invalid", - "input": { - "event_kind": 30402, - "content": "# Widget", - "tags": [ - ["d", "AAAAAAAAAAAAAAAAAAAAAg"], - ["p", "farm_pubkey"], - ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], - ["key", "sku"], - ["title", "Widget"], - ["category", "Tools"], - ["radroots:primary_bin", "bin-1"], - ["radroots:bin", "bin-1", "1", "each"], - ["radroots:price", "bin-1", "10", "USD", "1", "each"], - ["radroots:discount", "{"] - ] - }, - "expected": { - "error": {"InvalidDiscount": "radroots:discount"} - } - }, - { - "id": "operational_listing_private_json_location_001", - "kind": "listing.parse_event.invalid", - "input": { - "event_kind": 30402, - "content": "{\"location\":{\"coordinates\":[1,2]}}", - "tags": [ - ["d", "AAAAAAAAAAAAAAAAAAAAAg"], - ["p", "farm_pubkey"], - ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], - ["key", "sku"], - ["title", "Widget"], - ["category", "Tools"], - ["radroots:primary_bin", "bin-1"], - ["radroots:bin", "bin-1", "1", "each"], - ["radroots:price", "bin-1", "10", "USD", "1", "each"] - ] - }, - "expected": { - "error": {"InvalidJson": "location"} - } - }, - { - "id": "operational_listing_invalid_kind_001", - "kind": "listing.parse_event.invalid", - "input": { - "event_kind": 0, - "content": "# Widget", - "tags": [ - ["d", "AAAAAAAAAAAAAAAAAAAAAg"], - ["p", "farm_pubkey"], - ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], - ["key", "sku"], - ["title", "Widget"], - ["category", "Tools"], - ["radroots:primary_bin", "bin-1"], - ["radroots:bin", "bin-1", "1", "each"], - ["radroots:price", "bin-1", "10", "USD", "1", "each"] - ] - }, - "expected": { - "error": {"InvalidKind": 0} - } - } - ] -} diff --git a/contracts/conformance/vectors/operational_listing/build_draft.v1.json b/contracts/conformance/vectors/operational_listing/build_draft.v1.json @@ -0,0 +1,33 @@ +{ + "suite": "operational_listing", + "contract_version": "1.0.0", + "vectors": [ + { + "id": "operational_listing_build_draft_minimal_001", + "kind": "operational_listing.build_draft", + "input": { + "listing": { + "d_tag": "AAAAAAAAAAAAAAAAAAAAAg", + "farm": { + "pubkey": "farm_pubkey", + "d_tag": "AAAAAAAAAAAAAAAAAAAAAA" + }, + "product": { + "key": "sku", + "title": "widget", + "category": "tools" + }, + "primary_bin_id": "bin-1", + "bins": [] + } + }, + "expected": { + "wire_parts": { + "kind": "classified_listing", + "content_shape": "operational_listing_markdown_or_empty", + "tags_shape": "operational_listing_tags_full" + } + } + } + ] +} diff --git a/contracts/conformance/vectors/operational_listing/build_tags.v1.json b/contracts/conformance/vectors/operational_listing/build_tags.v1.json @@ -0,0 +1,29 @@ +{ + "suite": "operational_listing", + "contract_version": "1.0.0", + "vectors": [ + { + "id": "operational_listing_build_tags_minimal_001", + "kind": "operational_listing.build_tags", + "input": { + "listing": { + "d_tag": "AAAAAAAAAAAAAAAAAAAAAg", + "farm": { + "pubkey": "farm_pubkey", + "d_tag": "AAAAAAAAAAAAAAAAAAAAAA" + }, + "product": { + "key": "sku", + "title": "widget", + "category": "tools" + }, + "primary_bin_id": "bin-1", + "bins": [] + } + }, + "expected": { + "tags_shape": "operational_listing_tags" + } + } + ] +} diff --git a/contracts/conformance/vectors/operational_listing/parse_event.v1.json b/contracts/conformance/vectors/operational_listing/parse_event.v1.json @@ -0,0 +1,215 @@ +{ + "suite": "operational_listing", + "contract_version": "1.0.0", + "vectors": [ + { + "id": "operational_listing_tags_override_json_content_001", + "kind": "operational_listing.parse_event.valid", + "input": { + "event_kind": 30402, + "content": "{\"product\":{\"title\":\"Content override\",\"category\":\"Content category\"},\"inventory_available\":\"99\"}", + "tags": [ + ["d", "AAAAAAAAAAAAAAAAAAAAAg"], + ["p", "farm_pubkey"], + ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "sku"], + ["title", "Widget"], + ["category", "Tools"], + ["radroots:primary_bin", "bin-1"], + ["radroots:bin", "bin-1", "1", "each"], + ["radroots:price", "bin-1", "10", "USD", "1", "each"], + ["price", "10", "USD"] + ] + }, + "expected": { + "listing": { + "d_tag": "AAAAAAAAAAAAAAAAAAAAAg", + "title": "Widget", + "category": "Tools", + "primary_bin_id": "bin-1", + "quantity_amount": "1", + "quantity_unit": "each", + "price_amount": "10", + "price_currency": "USD", + "inventory_available": null + } + } + }, + { + "id": "operational_listing_missing_d_001", + "kind": "operational_listing.parse_event.invalid", + "input": { + "event_kind": 30402, + "content": "# Widget", + "tags": [ + ["p", "farm_pubkey"], + ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "sku"], + ["title", "Widget"], + ["category", "Tools"], + ["radroots:primary_bin", "bin-1"], + ["radroots:bin", "bin-1", "1", "each"], + ["radroots:price", "bin-1", "10", "USD", "1", "each"] + ] + }, + "expected": { + "error": {"MissingTag": "d"} + } + }, + { + "id": "operational_listing_invalid_d_001", + "kind": "operational_listing.parse_event.invalid", + "input": { + "event_kind": 30402, + "content": "# Widget", + "tags": [ + ["d", "bad d"], + ["p", "farm_pubkey"], + ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "sku"], + ["title", "Widget"], + ["category", "Tools"], + ["radroots:primary_bin", "bin-1"], + ["radroots:bin", "bin-1", "1", "each"], + ["radroots:price", "bin-1", "10", "USD", "1", "each"] + ] + }, + "expected": { + "error": {"InvalidTag": "d"} + } + }, + { + "id": "operational_listing_invalid_number_001", + "kind": "operational_listing.parse_event.invalid", + "input": { + "event_kind": 30402, + "content": "# Widget", + "tags": [ + ["d", "AAAAAAAAAAAAAAAAAAAAAg"], + ["p", "farm_pubkey"], + ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "sku"], + ["title", "Widget"], + ["category", "Tools"], + ["radroots:primary_bin", "bin-1"], + ["radroots:bin", "bin-1", "bad", "each"], + ["radroots:price", "bin-1", "10", "USD", "1", "each"] + ] + }, + "expected": { + "error": {"InvalidNumber": "radroots:bin"} + } + }, + { + "id": "operational_listing_invalid_unit_001", + "kind": "operational_listing.parse_event.invalid", + "input": { + "event_kind": 30402, + "content": "# Widget", + "tags": [ + ["d", "AAAAAAAAAAAAAAAAAAAAAg"], + ["p", "farm_pubkey"], + ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "sku"], + ["title", "Widget"], + ["category", "Tools"], + ["radroots:primary_bin", "bin-1"], + ["radroots:bin", "bin-1", "1", "bad"], + ["radroots:price", "bin-1", "10", "USD", "1", "each"] + ] + }, + "expected": { + "error": "InvalidUnit" + } + }, + { + "id": "operational_listing_invalid_currency_001", + "kind": "operational_listing.parse_event.invalid", + "input": { + "event_kind": 30402, + "content": "# Widget", + "tags": [ + ["d", "AAAAAAAAAAAAAAAAAAAAAg"], + ["p", "farm_pubkey"], + ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "sku"], + ["title", "Widget"], + ["category", "Tools"], + ["radroots:primary_bin", "bin-1"], + ["radroots:bin", "bin-1", "1", "each"], + ["radroots:price", "bin-1", "10", "US", "1", "each"] + ] + }, + "expected": { + "error": "InvalidCurrency" + } + }, + { + "id": "operational_listing_invalid_discount_001", + "kind": "operational_listing.parse_event.invalid", + "input": { + "event_kind": 30402, + "content": "# Widget", + "tags": [ + ["d", "AAAAAAAAAAAAAAAAAAAAAg"], + ["p", "farm_pubkey"], + ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "sku"], + ["title", "Widget"], + ["category", "Tools"], + ["radroots:primary_bin", "bin-1"], + ["radroots:bin", "bin-1", "1", "each"], + ["radroots:price", "bin-1", "10", "USD", "1", "each"], + ["radroots:discount", "{"] + ] + }, + "expected": { + "error": {"InvalidDiscount": "radroots:discount"} + } + }, + { + "id": "operational_listing_private_json_location_001", + "kind": "operational_listing.parse_event.invalid", + "input": { + "event_kind": 30402, + "content": "{\"location\":{\"coordinates\":[1,2]}}", + "tags": [ + ["d", "AAAAAAAAAAAAAAAAAAAAAg"], + ["p", "farm_pubkey"], + ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "sku"], + ["title", "Widget"], + ["category", "Tools"], + ["radroots:primary_bin", "bin-1"], + ["radroots:bin", "bin-1", "1", "each"], + ["radroots:price", "bin-1", "10", "USD", "1", "each"] + ] + }, + "expected": { + "error": {"InvalidJson": "location"} + } + }, + { + "id": "operational_listing_invalid_kind_001", + "kind": "operational_listing.parse_event.invalid", + "input": { + "event_kind": 0, + "content": "# Widget", + "tags": [ + ["d", "AAAAAAAAAAAAAAAAAAAAAg"], + ["p", "farm_pubkey"], + ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "sku"], + ["title", "Widget"], + ["category", "Tools"], + ["radroots:primary_bin", "bin-1"], + ["radroots:bin", "bin-1", "1", "each"], + ["radroots:price", "bin-1", "10", "USD", "1", "each"] + ] + }, + "expected": { + "error": {"InvalidKind": 0} + } + } + ] +} diff --git a/contracts/conformance/vectors/social/upgraded_boundaries.v1.json b/contracts/conformance/vectors/social/upgraded_boundaries.v1.json @@ -3,12 +3,12 @@ "contract_version": "1.0.0", "vectors": [ { - "id": "social_listing_published_at_valid_001", - "kind": "social.listing.parse_event.valid", + "id": "social_operational_listing_published_at_valid_001", + "kind": "social.operational_listing.parse_event.valid", "input": { "event": { "kind": 30402, - "content_shape": "listing_markdown", + "content_shape": "operational_listing_markdown", "tags": [ [ "published_at", @@ -23,12 +23,12 @@ } }, { - "id": "social_listing_published_at_invalid_002", - "kind": "social.listing.parse_event.invalid", + "id": "social_operational_listing_published_at_invalid_002", + "kind": "social.operational_listing.parse_event.invalid", "input": { "event": { "kind": 30402, - "content_shape": "listing_markdown", + "content_shape": "operational_listing_markdown", "tags": [ [ "published_at", @@ -189,8 +189,8 @@ "kind": "social.surface_kinds.classification.valid", "input": { "kinds": { - "active_listing": 30402, - "retired_listing_kind": 30403, + "classified_listing": 30402, + "retired_legacy_listing_kind": 30403, "report": 1984, "farm_crdt_change": 78, "relay_auth": 22242, @@ -199,16 +199,16 @@ }, "expected": { "result": "ok", - "home_feed_active_listing": true, - "home_feed_retired_listing_kind": false, + "home_feed_classified_listing": true, + "home_feed_retired_legacy_listing_kind": false, "home_feed_report": false, "home_feed_farm_crdt_change": false, "home_feed_relay_auth": false, "home_feed_http_auth": false, - "market_active_listing": true, - "market_retired_listing_kind": false, + "market_classified_listing": true, + "market_retired_legacy_listing_kind": false, "moderation_report": true, - "retired_listing_kind_accepted": false, + "retired_legacy_listing_kind_accepted": false, "private_farm_ops_farm_crdt_change": true, "private_farm_ops_relay_auth": false, "private_farm_ops_http_auth": false diff --git a/contracts/conformance/vectors/trade/parse_classified_listing_address.v1.json b/contracts/conformance/vectors/trade/parse_classified_listing_address.v1.json @@ -0,0 +1,16 @@ +{ + "suite": "trade", + "contract_version": "1.0.0", + "vectors": [ + { + "id": "trade_parse_classified_listing_address_minimal_001", + "kind": "trade.parse_classified_listing_address", + "input": { + "listing_addr": "30402:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:AAAAAAAAAAAAAAAAAAAAAg" + }, + "expected": { + "address_shape": "classified_listing_address" + } + } + ] +} diff --git a/contracts/conformance/vectors/trade/parse_listing_address.v1.json b/contracts/conformance/vectors/trade/parse_listing_address.v1.json @@ -1,16 +0,0 @@ -{ - "suite": "trade", - "contract_version": "1.0.0", - "vectors": [ - { - "id": "trade_parse_listing_address_minimal_001", - "kind": "trade.parse_listing_address", - "input": { - "listing_addr": "30402:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:AAAAAAAAAAAAAAAAAAAAAg" - }, - "expected": { - "address_shape": "trade_listing_address" - } - } - ] -} diff --git a/contracts/conformance/vectors/trade_validation/validate_listing_event.v1.json b/contracts/conformance/vectors/trade_validation/validate_listing_event.v1.json @@ -1,20 +0,0 @@ -{ - "suite": "trade_validation", - "contract_version": "1.0.0", - "vectors": [ - { - "id": "trade_validation_validate_listing_event_minimal_001", - "kind": "trade_validation.validate_listing_event", - "input": { - "event": { - "kind": "listing", - "content_shape": "listing_json_or_markdown", - "tags_shape": "listing_tags_full" - } - }, - "expected": { - "validation_shape": "trade_validation_receipt" - } - } - ] -} diff --git a/contracts/conformance/vectors/trade_validation/validate_operational_listing_event.v1.json b/contracts/conformance/vectors/trade_validation/validate_operational_listing_event.v1.json @@ -0,0 +1,20 @@ +{ + "suite": "trade_validation", + "contract_version": "1.0.0", + "vectors": [ + { + "id": "trade_validation_validate_operational_listing_event_minimal_001", + "kind": "trade_validation.validate_operational_listing_event", + "input": { + "event": { + "kind": "classified_listing", + "content_shape": "operational_listing_json_or_markdown", + "tags_shape": "operational_listing_tags_full" + } + }, + "expected": { + "validation_shape": "operational_listing_trade_projection" + } + } + ] +} diff --git a/contracts/event_boundary_matrix.md b/contracts/event_boundary_matrix.md @@ -31,6 +31,16 @@ an envelope whose id and signature it has independently verified and whose kind the corresponding parser accepted. Outbound authored models produce unsigned wire parts and require runtime signing and transport. +## Classified and operational listing boundary rule + +Kind `30402` is the standard NIP-99 classified-listing kind and +`RadrootsClassifiedListingAddress` is the exact coordinate authority for that +kind. `RadrootsOperationalListing` is the richer +`radroots.operational_listing.published.v1` profile accepted at the same kind; +its typed codec, tags, and publication operations are exposed under the +`operational_listing` domain. The standard kind identity does not by itself +establish that an event satisfies the operational profile. + ## Kind-1 post boundary rule Ordinary kind-1 events remain interoperable at the generic @@ -80,7 +90,7 @@ implementation. | document | 30361 | RadrootsDocument | events.document.publish, events.document.list, events.document.get | requires `d` and pubkey tags; optional address tag | | resource_area | 30370 | RadrootsResourceArea | events.resource_area.publish, events.resource_area.list, events.resource_area.get | addressable; GCS location and `g` tag required | | resource_cap | 30371 | RadrootsResourceHarvestCap | events.resource_cap.publish, events.resource_cap.list, events.resource_cap.get | addressable; required address, pubkey, key, start, and end tags | -| listing | 30402 | RadrootsListing | events.listing.publish, events.listing.list, events.listing.get | canonical Markdown content and tags; farm author required | +| operational_listing | 30402 | RadrootsOperationalListing | events.operational_listing.publish, events.operational_listing.list, events.operational_listing.get | NIP-99 classified-listing kind with the richer Radroots operational profile; canonical Markdown content and tags; farm author required | | dvm_request | 5000-5999 | RadrootsJobRequest | events.dvm_request.publish, events.dvm_request.list, events.dvm_request.get | generic DVM request surface | | dvm_result | 6000-6999 | RadrootsJobResult | events.dvm_result.publish, events.dvm_result.list, events.dvm_result.get | generic DVM result surface | | dvm_feedback | 7000 | RadrootsJobFeedback | events.dvm_feedback.publish, events.dvm_feedback.list, events.dvm_feedback.get | generic DVM feedback surface | diff --git a/contracts/events/social-events.md b/contracts/events/social-events.md @@ -7,9 +7,10 @@ this repository. ## Purpose -The public social event substrate extends the Radroots event family beyond profile, farm, listing, -and trade workflows while keeping relay runtime behavior, application projections, moderation -services, and private Field business documents outside this repository's event-contract boundary. +The public social event substrate extends the Radroots event family beyond profile, farm, +operational listings, and trade workflows while keeping relay runtime behavior, application +projections, moderation services, and private Field business documents outside this repository's +event-contract boundary. The target implementation is standards-first and Radroots-named. Event models live in `radroots_event`, canonical encode/decode behavior lives in `radroots_event_codec`, and @@ -27,8 +28,8 @@ authoring and admission profile are separate contract layers. ## Implementation Inventory The repository implements strict authored and verified-projected kind `1` post profiles, kind `1111` -`RadrootsComment`, kind `7` `RadrootsReaction`, generic `RadrootsList` entries, stable listing -records through `RadrootsListing`, articles, generic public file metadata, calendar date events, +`RadrootsComment`, kind `7` `RadrootsReaction`, generic `RadrootsList` entries, operational listing +records through `RadrootsOperationalListing`, articles, generic public file metadata, calendar date events, calendar time events, reposts, generic reposts, calendar collections, RSVP events, and reports. The closeout contract requires: @@ -38,7 +39,7 @@ The closeout contract requires: - ordinary kind-1 compatibility reads plus strict Update, PhotoUpdate, and Ask authoring - strict NIP-22 `RadrootsComment` behavior without legacy `e_root` or `e_prev` fallback tags - strict NIP-25 `RadrootsReaction` behavior where empty content is a valid like -- explicit optional `published_at` support for NIP-99 listing parity +- explicit optional `published_at` support for NIP-99 classified-listing parity - NIP-65 relay-list validation evidence through `RadrootsList` - conformance vectors and canonical-event witnesses for every new or upgraded social event family @@ -69,7 +70,8 @@ The production-v1 public social substrate includes: `RadrootsParsedNip52CalendarEventRsvp`, and strict admitted `RadrootsAdmittedCalendarEventRsvp` models for NIP-52 kind `31925` - `RadrootsReport` for NIP-56 kind `1984` -- stable listing kind `30402` validation through `RadrootsListing` +- operational-listing profile validation through `RadrootsOperationalListing` at NIP-99 + classified-listing kind `30402` - relay-list kind `10002` validation through `RadrootsList` ## Contract Decisions @@ -138,11 +140,11 @@ contract. Strict Profile, Update, PhotoUpdate, and Ask contracts are `TypedOnly`; `radroots.social.post.v1` is `ReadOnly`; ordinary generic-draft contracts remain `GenericDraft`. `RadrootsEventDraft::new` therefore rejects the strict Profile contract and all four governed kind-1 post contracts with -`contract_not_draft_authorable`. Serialized drafts record registry version `2` +`contract_not_draft_authorable`. Serialized drafts record registry version `3` and are accepted only after deserialization revalidates the registry version, contract, kind, shape, policy, recomputed event id, and known fields. The frozen-draft signing boundary repeats that validation, so stale version-`1` -drafts must be rebuilt. Typed root posts enter Nostr signing and client +and version-`2` drafts must be rebuilt. Typed root posts enter Nostr signing and client publication only through an opaque post builder that exposes timestamp selection and signing, but no raw tag/content mutation or public conversion to the upstream builder. The opaque generic builder rejects kind `0` and unmarked @@ -317,17 +319,18 @@ format-safety policy. No calendar model upgrades an observed relay URL into eith or an availability guarantee. Product routing uses surface-specific kind classifiers rather than a broad public-social set. Home, -Events, Market, Map, and Profile public-content candidates are explicit. Active listing kind `30402` +Events, Market, Map, and Profile public-content candidates are explicit. Active NIP-99 +classified-listing kind `30402` can appear in public product surfaces. Report kind `1984` is a moderation/admin candidate, not normal feed content. Relay and HTTP auth kinds are transient and excluded from durable social and farm-ops candidate sets. Private farm operations candidates include the farm workspace manifest, farm CRDT change envelope, farm file metadata, and the supported NIP-29 group event subset. -`RadrootsRelayList` is not a separate model type in the target contract. Stable listings are -represented through `RadrootsListing`, and NIP-51 standard and list-set entries, including NIP-65 -relay metadata kind `10002`, are represented through `RadrootsList`. NIP-51 taxonomy may classify -kind `31924` as a calendar list, but generic list and list-set decoding or authoring must reject that kind; -only the calendar-specific model and codec may parse or publish it. +`RadrootsRelayList` is not a separate model type in the target contract. Operational listing records +are represented through `RadrootsOperationalListing`, and NIP-51 standard and list-set entries, +including NIP-65 relay metadata kind `10002`, are represented through `RadrootsList`. NIP-51 +taxonomy may classify kind `31924` as a calendar list, but generic list and list-set decoding or +authoring must reject that kind; only the calendar-specific model and codec may parse or publish it. ## Exclusions @@ -350,7 +353,7 @@ this contract boundary. Every new social codec and every upgraded existing social codec must have deterministic valid and invalid conformance vectors before closeout. Upgraded vectors must include the strict comment, -reaction, listing, farm, list, and list-set behavior whose public contract changes during the +reaction, operational-listing, farm, list, and list-set behavior whose public contract changes during the refactor. Social vectors are repo-owned and synthetic. They must not depend on application relay state, local diff --git a/contracts/knowledge/knowledge_event_contract_manifest.v2.json b/contracts/knowledge/knowledge_event_contract_manifest.v2.json @@ -1,6 +1,6 @@ { "schema_version": 2, - "registry_version": 2, + "registry_version": 3, "radroots_event_version": "1.0.0-alpha.1", "radroots_event_codec_version": "1.0.0-alpha.1", "contract_count": 11, diff --git a/contracts/knowledge/knowledge_event_contract_manifest.v2.sha256 b/contracts/knowledge/knowledge_event_contract_manifest.v2.sha256 @@ -1 +1 @@ -19c3baa86b56f418d5aa8eefbd315b5685d54deb068ca29e96fd8dec8b0ef7e1 +9a8f8495aa9bbc040a707e2b77c415aa71949164cdf3647c91060d3baf36fe6f diff --git a/contracts/operations.toml b/contracts/operations.toml @@ -9,7 +9,7 @@ domains = [ "event", "profile", "farm", - "listing", + "operational_listing", "trade", "trade_validation", "social", @@ -54,7 +54,7 @@ public = [ "RadrootsEventHeadDecision", "RadrootsEventRef", "RadrootsEventPtr", - "RadrootsListingAddress", + "RadrootsClassifiedListingAddress", "RadrootsAuthoredImage", "RadrootsAuthoredImageError", "RadrootsNip05Identifier", @@ -69,7 +69,7 @@ public = [ "RadrootsNip05IdentityVerification", "RadrootsUnverifiedProfileMediaReference", "RadrootsFarm", - "RadrootsListing", + "RadrootsOperationalListing", "RadrootsPost", "RadrootsAuthoredPostError", "RadrootsPostImageDimensions", @@ -515,69 +515,69 @@ rust_types = ["radroots_event::farm::RadrootsFarm"] [operations.farm_build_draft.conformance] vector = "contracts/conformance/vectors/farm/build_draft.v1.json" -[operations.listing_build_tags] -domain = "listing" -id = "listing.build_tags" +[operations.operational_listing_build_tags] +domain = "operational_listing" +id = "operational_listing.build_tags" stability = "beta" -inputs = ["RadrootsListing"] +inputs = ["RadrootsOperationalListing"] outputs = ["NostrTags"] error_class = "encode_error" deterministic = true signing = "native" transport = "native" -[operations.listing_build_tags.implementation] +[operations.operational_listing_build_tags.implementation] rust_modules = [ - "crates/event_codec/src/listing/encode.rs", - "crates/event_codec/src/listing/tags.rs", + "crates/event_codec/src/operational_listing/encode.rs", + "crates/event_codec/src/operational_listing/tags.rs", ] -rust_types = ["radroots_event::listing::RadrootsListing"] +rust_types = ["radroots_event::operational_listing::RadrootsOperationalListing"] -[operations.listing_build_tags.conformance] -vector = "contracts/conformance/vectors/listing/build_tags.v1.json" +[operations.operational_listing_build_tags.conformance] +vector = "contracts/conformance/vectors/operational_listing/build_tags.v1.json" -[operations.listing_build_draft] -domain = "listing" -id = "listing.build_draft" +[operations.operational_listing_build_draft] +domain = "operational_listing" +id = "operational_listing.build_draft" stability = "beta" -inputs = ["RadrootsListing"] +inputs = ["RadrootsOperationalListing"] outputs = ["RadrootsNip01EventWireParts"] error_class = "encode_error" deterministic = true signing = "native" transport = "native" -[operations.listing_build_draft.implementation] +[operations.operational_listing_build_draft.implementation] rust_modules = [ - "crates/event_codec/src/listing/encode.rs", + "crates/event_codec/src/operational_listing/encode.rs", "crates/event_codec/src/wire.rs", ] -rust_types = ["radroots_event::listing::RadrootsListing"] +rust_types = ["radroots_event::operational_listing::RadrootsOperationalListing"] -[operations.listing_build_draft.conformance] -vector = "contracts/conformance/vectors/listing/build_draft.v1.json" +[operations.operational_listing_build_draft.conformance] +vector = "contracts/conformance/vectors/operational_listing/build_draft.v1.json" -[operations.listing_parse_event] -domain = "listing" -id = "listing.parse_event" +[operations.operational_listing_parse_event] +domain = "operational_listing" +id = "operational_listing.parse_event" stability = "beta" inputs = ["RadrootsEventEnvelope"] -outputs = ["RadrootsListing"] +outputs = ["RadrootsOperationalListing"] error_class = "parse_error" deterministic = true signing = "native" transport = "native" -[operations.listing_parse_event.implementation] -rust_modules = ["crates/event_codec/src/listing/decode.rs"] +[operations.operational_listing_parse_event.implementation] +rust_modules = ["crates/event_codec/src/operational_listing/decode.rs"] rust_types = [ "radroots_event::RadrootsEventEnvelope", - "radroots_event::order::RadrootsListingParseError", - "radroots_event::listing::RadrootsListing", + "radroots_event::operational_listing::RadrootsOperationalListingParseError", + "radroots_event::operational_listing::RadrootsOperationalListing", ] -[operations.listing_parse_event.conformance] -vector = "contracts/conformance/vectors/listing/parse_event.v1.json" +[operations.operational_listing_parse_event.conformance] +vector = "contracts/conformance/vectors/operational_listing/parse_event.v1.json" [operations.social_update_build_authored_draft] domain = "social" @@ -1211,23 +1211,23 @@ rust_types = [ [operations.trade_parse_mutation_event.conformance] vector = "contracts/conformance/vectors/trade/parse_mutation_event.v1.json" -[operations.trade_parse_listing_address] +[operations.trade_parse_classified_listing_address] domain = "trade" -id = "trade.parse_listing_address" +id = "trade.parse_classified_listing_address" stability = "beta" inputs = ["listing_addr"] -outputs = ["RadrootsListingAddress"] +outputs = ["RadrootsClassifiedListingAddress"] error_class = "address_error" deterministic = true signing = "native" transport = "native" -[operations.trade_parse_listing_address.implementation] +[operations.trade_parse_classified_listing_address.implementation] rust_modules = ["crates/event/src/ids.rs"] -rust_types = ["radroots_event::ids::RadrootsListingAddress"] +rust_types = ["radroots_event::ids::RadrootsClassifiedListingAddress"] -[operations.trade_parse_listing_address.conformance] -vector = "contracts/conformance/vectors/trade/parse_listing_address.v1.json" +[operations.trade_parse_classified_listing_address.conformance] +vector = "contracts/conformance/vectors/trade/parse_classified_listing_address.v1.json" [operations.trade_reduce_records] domain = "trade" @@ -1250,26 +1250,26 @@ rust_types = [ [operations.trade_reduce_records.conformance] vector = "contracts/conformance/vectors/trade/reduce_records.v1.json" -[operations.trade_validation_validate_listing_event] +[operations.trade_validation_validate_operational_listing_event] domain = "trade_validation" -id = "trade_validation.validate_listing_event" +id = "trade_validation.validate_operational_listing_event" stability = "beta" inputs = ["RadrootsEventEnvelope"] -outputs = ["TradeListingValidateResult"] +outputs = ["RadrootsOperationalListingTradeProjection"] error_class = "validation_error" deterministic = true signing = "native" transport = "native" -[operations.trade_validation_validate_listing_event.implementation] -rust_modules = ["crates/trade/src/listing/validation.rs"] +[operations.trade_validation_validate_operational_listing_event.implementation] +rust_modules = ["crates/trade/src/operational_listing/validation.rs"] rust_types = [ "radroots_event::RadrootsEventEnvelope", - "radroots_trade::listing::validation::RadrootsTradeListing", + "radroots_trade::operational_listing::validation::RadrootsOperationalListingTradeProjection", ] -[operations.trade_validation_validate_listing_event.conformance] -vector = "contracts/conformance/vectors/trade_validation/validate_listing_event.v1.json" +[operations.trade_validation_validate_operational_listing_event.conformance] +vector = "contracts/conformance/vectors/trade_validation/validate_operational_listing_event.v1.json" [operations.knowledge_contract_manifest_json] domain = "knowledge" diff --git a/contracts/releases/1.0.0-alpha.1.toml b/contracts/releases/1.0.0-alpha.1.toml @@ -117,4 +117,22 @@ semver_impacts = [ "change_exported_function_signature", "change_exported_algorithm_behavior", ] -summary = "Consolidate operational listing decoding in radroots_event_codec, return the typed listing error contract, and make canonical tags authoritative over JSON content." +summary = "Consolidate operational listing decoding in radroots_event_codec, return the typed operational-listing error contract, and make canonical tags authoritative over JSON content." + +[[changes]] +id = "classified-operational-listing-taxonomy" +classification = "breaking" +semver_impacts = [ + "add_exported_type", + "add_exported_constant", + "add_enum_variant", + "add_conformance_vector", + "remove_exported_type", + "remove_exported_function", + "remove_exported_module", + "change_exported_function_signature", + "change_exported_enum_variant", + "change_exported_constant_value", + "change_exported_algorithm_behavior", +] +summary = "Separate the standard NIP-99 Classified Listing kind and coordinate authority from the richer Radroots Operational Listing profile, operations, codecs, and conformance namespace." diff --git a/crates/authority/src/actor.rs b/crates/authority/src/actor.rs @@ -428,7 +428,7 @@ mod tests { let request = RadrootsActorResolutionRequest::new( selector, RadrootsActorRole::Seller, - Some("radroots.listing.published.v1".to_owned()), + Some("radroots.operational_listing.published.v1".to_owned()), ); assert_eq!( @@ -436,7 +436,10 @@ mod tests { &RadrootsActorSelector::account_id("acct-field-01").expect("selector") ); assert_eq!(request.required_role(), RadrootsActorRole::Seller); - assert_eq!(request.contract_id(), Some("radroots.listing.published.v1")); + assert_eq!( + request.contract_id(), + Some("radroots.operational_listing.published.v1") + ); } #[test] diff --git a/crates/authority/src/authorization.rs b/crates/authority/src/authorization.rs @@ -170,9 +170,11 @@ mod tests { use super::*; use crate::RadrootsSignerError; use core::cell::Cell; - use radroots_event::contract::{RadrootsActorRole, event_contract}; + use radroots_event::contract::{ + RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION, RadrootsActorRole, event_contract, + }; use radroots_event::ids::RadrootsPublicKey; - use radroots_event::kinds::{KIND_LISTING, KIND_POST, KIND_TRADE_PROPOSAL}; + use radroots_event::kinds::{KIND_CLASSIFIED_LISTING, KIND_POST, KIND_TRADE_PROPOSAL}; fn hex_64(character: char) -> String { std::iter::repeat_n(character, 64).collect() @@ -190,16 +192,38 @@ mod tests { RadrootsActorContext::explicit_pubkey(pubkey, [RadrootsActorRole::Buyer]).expect("buyer") } - fn listing_event_draft(pubkey: &str) -> RadrootsEventDraft { + fn operational_listing_event_draft(pubkey: &str) -> RadrootsEventDraft { RadrootsEventDraft::new( - "radroots.listing.published.v1", - KIND_LISTING, + "radroots.operational_listing.published.v1", + KIND_CLASSIFIED_LISTING, 1_700_000_000, - vec![vec!["d".to_owned(), "listing-a".to_owned()]], - "{}", + vec![ + vec!["d".to_owned(), "listing-a".to_owned()], + vec!["p".to_owned(), pubkey.to_owned()], + vec!["a".to_owned(), format!("30340:{pubkey}:farm-a")], + vec!["key".to_owned(), "carrots".to_owned()], + vec!["title".to_owned(), "Carrots".to_owned()], + vec!["category".to_owned(), "produce".to_owned()], + vec!["radroots:primary_bin".to_owned(), "field-bin".to_owned()], + vec![ + "radroots:bin".to_owned(), + "field-bin".to_owned(), + "1000".to_owned(), + "g".to_owned(), + ], + vec![ + "radroots:price".to_owned(), + "field-bin".to_owned(), + "3".to_owned(), + "CAD".to_owned(), + "1".to_owned(), + "lb".to_owned(), + ], + ], + "# Carrots", pubkey, ) - .expect("listing event draft") + .expect("operational listing event draft") } #[derive(Default)] @@ -368,7 +392,8 @@ mod tests { #[test] fn buyer_and_seller_contract_roles_match_current_contracts() { - let listing = event_contract("radroots.listing.published.v1").expect("listing contract"); + let operational_listing = event_contract("radroots.operational_listing.published.v1") + .expect("operational listing contract"); let trade_proposal = event_contract("radroots.trade.proposal.v1").expect("trade proposal contract"); let trade_decision = @@ -376,10 +401,10 @@ mod tests { let seller = seller_actor(hex_64('a').as_str()); let buyer = buyer_actor(hex_64('b').as_str()); - assert_eq!(listing.author_role, RadrootsActorRole::Seller); - assert!(authorize_actor_for_contract(&seller, listing).is_ok()); + assert_eq!(operational_listing.author_role, RadrootsActorRole::Seller); + assert!(authorize_actor_for_contract(&seller, operational_listing).is_ok()); assert!(matches!( - authorize_actor_for_contract(&buyer, listing), + authorize_actor_for_contract(&buyer, operational_listing), Err(RadrootsAuthorityError::ActorRoleUnsatisfied { .. }) )); assert!(authorize_actor_for_contract(&buyer, trade_proposal).is_ok()); @@ -397,7 +422,7 @@ mod tests { #[test] fn actor_pubkey_mismatch_fails() { - let draft = listing_event_draft(hex_64('a').as_str()); + let draft = operational_listing_event_draft(hex_64('a').as_str()); let actor = seller_actor(hex_64('b').as_str()); assert!(matches!( @@ -408,7 +433,7 @@ mod tests { #[test] fn signer_pubkey_mismatch_fails() { - let draft = listing_event_draft(hex_64('a').as_str()); + let draft = operational_listing_event_draft(hex_64('a').as_str()); let signer = StaticSigner::new(hex_64('b').as_str()); assert!(matches!( @@ -418,36 +443,39 @@ mod tests { } #[test] - fn draft_validation_failure_does_not_invoke_custom_signer() { + fn stale_v1_and_v2_draft_validation_failures_do_not_invoke_custom_signer() { let pubkey = hex_64('a'); - let draft = listing_event_draft(pubkey.as_str()); + let draft = operational_listing_event_draft(pubkey.as_str()); let actor = seller_actor(pubkey.as_str()); let signer = CountingSigner::new(pubkey.as_str()); - let error = sign_authorized_draft_with_validator(&actor, &signer, &draft, |_| { - Err(RadrootsDraftError::ContractRegistryVersionMismatch { - expected: 2, - actual: 1, + assert_eq!(RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION, 3); + for actual in [1, 2] { + let error = sign_authorized_draft_with_validator(&actor, &signer, &draft, |_| { + Err(RadrootsDraftError::ContractRegistryVersionMismatch { + expected: RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION, + actual, + }) }) - }) - .expect_err("stale draft validation must fail"); - - assert!(matches!( - error, - RadrootsAuthorityError::DraftValidation( - RadrootsDraftError::ContractRegistryVersionMismatch { - expected: 2, - actual: 1 - } - ) - )); + .expect_err("stale draft validation must fail"); + + assert_eq!( + error, + RadrootsAuthorityError::DraftValidation( + RadrootsDraftError::ContractRegistryVersionMismatch { + expected: 3, + actual, + } + ) + ); + } assert_eq!(signer.sign_invocations.get(), 0); } #[test] fn signer_explicit_id_mismatch_fails_before_authorized_signing() { let pubkey = hex_64('a'); - let draft = listing_event_draft(pubkey.as_str()); + let draft = operational_listing_event_draft(pubkey.as_str()); let actor = seller_actor(pubkey.as_str()); let signer = StaticSigner::with_event_id(pubkey.as_str(), hex_64('e')); @@ -462,7 +490,7 @@ mod tests { #[test] fn signed_event_created_at_mismatch_fails() { let pubkey = hex_64('a'); - let draft = listing_event_draft(pubkey.as_str()); + let draft = operational_listing_event_draft(pubkey.as_str()); let actor = seller_actor(pubkey.as_str()); let signer = StaticSigner::with_overrides( pubkey.as_str(), @@ -481,7 +509,7 @@ mod tests { #[test] fn signed_event_kind_mismatch_fails() { let pubkey = hex_64('a'); - let draft = listing_event_draft(pubkey.as_str()); + let draft = operational_listing_event_draft(pubkey.as_str()); let actor = seller_actor(pubkey.as_str()); let signer = StaticSigner::with_overrides( pubkey.as_str(), @@ -494,7 +522,7 @@ mod tests { assert!(matches!( sign_authorized_draft(&actor, &signer, &draft), Err(RadrootsAuthorityError::SignedEventKindMismatch { - expected_kind: KIND_LISTING, + expected_kind: KIND_CLASSIFIED_LISTING, actual_kind: KIND_POST }) )); @@ -503,7 +531,7 @@ mod tests { #[test] fn signed_event_tags_mismatch_fails() { let pubkey = hex_64('a'); - let draft = listing_event_draft(pubkey.as_str()); + let draft = operational_listing_event_draft(pubkey.as_str()); let actor = seller_actor(pubkey.as_str()); let signer = StaticSigner::with_overrides( pubkey.as_str(), @@ -518,7 +546,7 @@ mod tests { assert_eq!( error, RadrootsAuthorityError::SignedEventTagsMismatch { - expected_len: 1, + expected_len: 9, actual_len: 1 } ); @@ -529,7 +557,7 @@ mod tests { #[test] fn signed_event_content_mismatch_fails() { let pubkey = hex_64('a'); - let draft = listing_event_draft(pubkey.as_str()); + let draft = operational_listing_event_draft(pubkey.as_str()); let actor = seller_actor(pubkey.as_str()); let signer = StaticSigner::with_overrides( pubkey.as_str(), @@ -544,7 +572,7 @@ mod tests { assert_eq!( error, RadrootsAuthorityError::SignedEventContentMismatch { - expected_len: 2, + expected_len: 9, actual_len: 16 } ); @@ -555,7 +583,7 @@ mod tests { #[test] fn signed_event_exactly_matching_draft_passes() { let pubkey = hex_64('a'); - let draft = listing_event_draft(pubkey.as_str()); + let draft = operational_listing_event_draft(pubkey.as_str()); let signed = signed_event_from_draft(&draft); validate_signed_event_matches_draft(&signed, &draft).expect("signed event matches draft"); @@ -564,7 +592,7 @@ mod tests { #[test] fn signed_event_pubkey_mismatch_fails() { let pubkey = hex_64('a'); - let draft = listing_event_draft(pubkey.as_str()); + let draft = operational_listing_event_draft(pubkey.as_str()); let signed = signed_event_from_parts( hex_64('b'), draft.created_at_u64(), @@ -594,7 +622,7 @@ mod tests { #[test] fn static_signer_maps_invalid_signed_event_parts() { let pubkey = hex_64('a'); - let draft = listing_event_draft(pubkey.as_str()); + let draft = operational_listing_event_draft(pubkey.as_str()); let signer = StaticSigner::with_event_id(pubkey.as_str(), "bad-id".to_owned()); assert!(matches!( @@ -606,7 +634,7 @@ mod tests { #[test] fn authorized_actor_and_signer_return_signed_event() { let pubkey = hex_64('a'); - let draft = listing_event_draft(pubkey.as_str()); + let draft = operational_listing_event_draft(pubkey.as_str()); let actor = seller_actor(pubkey.as_str()); let signer = StaticSigner::new(pubkey.as_str()); @@ -614,7 +642,7 @@ mod tests { assert_eq!(signed.id_str(), draft.expected_event_id_str()); assert_eq!(signed.pubkey_str(), draft.expected_pubkey_str()); - assert_eq!(signed.kind(), KIND_LISTING); + assert_eq!(signed.kind(), KIND_CLASSIFIED_LISTING); } #[test] diff --git a/crates/authority/src/error.rs b/crates/authority/src/error.rs @@ -294,10 +294,10 @@ mod tests { ), ( RadrootsAuthorityError::ActorRoleUnsatisfied { - contract_id: "radroots.listing.published.v1".to_owned(), + contract_id: "radroots.operational_listing.published.v1".to_owned(), required_role: RadrootsActorRole::Seller, }, - "actor does not satisfy role Seller for contract `radroots.listing.published.v1`", + "actor does not satisfy role Seller for contract `radroots.operational_listing.published.v1`", ), ( RadrootsAuthorityError::ActorPubkeyMismatch { @@ -407,24 +407,36 @@ mod tests { } #[test] - fn draft_validation_error_preserves_typed_source() { - let authority_error = RadrootsAuthorityError::DraftValidation( - radroots_event::draft::RadrootsDraftError::ContractRegistryVersionMismatch { - expected: 2, - actual: 1, - }, - ); + fn stale_v1_and_v2_draft_validation_errors_preserve_typed_sources() { + let cases = [ + ( + 1, + "draft validation failed: event contract registry version mismatch: expected 3, got 1", + "event contract registry version mismatch: expected 3, got 1", + ), + ( + 2, + "draft validation failed: event contract registry version mismatch: expected 3, got 2", + "event contract registry version mismatch: expected 3, got 2", + ), + ]; - assert_eq!( - authority_error.to_string(), - "draft validation failed: event contract registry version mismatch: expected 2, got 1" - ); - assert_eq!( - authority_error - .source() - .expect("draft validation source") - .to_string(), - "event contract registry version mismatch: expected 2, got 1" - ); + for (actual, expected_authority, expected_source) in cases { + let authority_error = RadrootsAuthorityError::DraftValidation( + radroots_event::draft::RadrootsDraftError::ContractRegistryVersionMismatch { + expected: 3, + actual, + }, + ); + + assert_eq!(authority_error.to_string(), expected_authority); + assert_eq!( + authority_error + .source() + .expect("draft validation source") + .to_string(), + expected_source + ); + } } } diff --git a/crates/event/src/contract.rs b/crates/event/src/contract.rs @@ -18,7 +18,7 @@ use crate::{ }; use radroots_blossom::RadrootsBlossomBlobUrl; -pub const RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION: u32 = 2; +pub const RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION: u32 = 3; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum RadrootsEventClass { @@ -50,6 +50,7 @@ pub enum RadrootsNostrStandard { Nip90, Nip94, Nip98, + Nip99, Radroots, } @@ -86,8 +87,8 @@ pub enum RadrootsReducer { FarmOpsProjection, GroupProjection, KnowledgeProjection, - ListingInventoryAccounting, - ListingProjection, + OperationalListingInventoryAccounting, + OperationalListingProjection, MarketProjection, OrderProjection, ProfileProjection, @@ -137,8 +138,8 @@ pub enum RadrootsTagSemantic { Identifier, Image, Kind, - ListingAddress, - ListingSnapshot, + ClassifiedListingAddress, + OperationalListingSnapshot, ListDescription, Location, Participant, @@ -723,18 +724,137 @@ const TAG_STATUS: RadrootsTagContract = tag( RadrootsTagValueType::Text, false, ); -const TAG_CATEGORY: RadrootsTagContract = tag( - "category", +const TAG_IMAGE: RadrootsTagContract = tag( + "image", RadrootsTagCardinality::OptionalMany, + RadrootsTagSemantic::Image, + RadrootsTagValueType::Url, + false, +); +const TAG_OPERATIONAL_LISTING_FARM: RadrootsTagContract = tag( + "a", + RadrootsTagCardinality::RequiredOne, + RadrootsTagSemantic::AddressableCoordinate, + RadrootsTagValueType::AddressableCoordinate, + true, +); +const TAG_OPERATIONAL_LISTING_PRODUCT_KEY: RadrootsTagContract = tag( + "key", + RadrootsTagCardinality::RequiredOne, RadrootsTagSemantic::Category, RadrootsTagValueType::Text, false, ); -const TAG_IMAGE: RadrootsTagContract = tag( - "image", +const TAG_OPERATIONAL_LISTING_TITLE: RadrootsTagContract = tag( + "title", + RadrootsTagCardinality::RequiredOne, + RadrootsTagSemantic::Title, + RadrootsTagValueType::Text, + false, +); +const TAG_OPERATIONAL_LISTING_CATEGORY: RadrootsTagContract = tag( + "category", + RadrootsTagCardinality::RequiredOne, + RadrootsTagSemantic::Category, + RadrootsTagValueType::Text, + false, +); +const TAG_OPERATIONAL_LISTING_PRIMARY_BIN: RadrootsTagContract = tag( + "radroots:primary_bin", + RadrootsTagCardinality::RequiredOne, + RadrootsTagSemantic::OperationalListingSnapshot, + RadrootsTagValueType::Text, + false, +); +const TAG_OPERATIONAL_LISTING_BIN: RadrootsTagContract = tag( + "radroots:bin", + RadrootsTagCardinality::RequiredMany, + RadrootsTagSemantic::OperationalListingSnapshot, + RadrootsTagValueType::Text, + false, +); +const TAG_OPERATIONAL_LISTING_PRICE: RadrootsTagContract = tag( + "radroots:price", + RadrootsTagCardinality::RequiredMany, + RadrootsTagSemantic::Price, + RadrootsTagValueType::Text, + false, +); +const TAG_OPERATIONAL_LISTING_DISCOUNT: RadrootsTagContract = tag( + "radroots:discount", RadrootsTagCardinality::OptionalMany, - RadrootsTagSemantic::Image, - RadrootsTagValueType::Url, + RadrootsTagSemantic::Price, + RadrootsTagValueType::Text, + false, +); +const TAG_OPERATIONAL_LISTING_RESOURCE_AREA: RadrootsTagContract = tag( + "radroots:resource_area", + RadrootsTagCardinality::OptionalOne, + RadrootsTagSemantic::AddressableCoordinate, + RadrootsTagValueType::AddressableCoordinate, + false, +); +const TAG_OPERATIONAL_LISTING_PLOT: RadrootsTagContract = tag( + "radroots:plot", + RadrootsTagCardinality::OptionalOne, + RadrootsTagSemantic::AddressableCoordinate, + RadrootsTagValueType::AddressableCoordinate, + false, +); +const TAG_OPERATIONAL_LISTING_INVENTORY: RadrootsTagContract = tag( + "inventory", + RadrootsTagCardinality::OptionalOne, + RadrootsTagSemantic::OperationalListingSnapshot, + RadrootsTagValueType::Text, + false, +); +const TAG_OPERATIONAL_LISTING_AVAILABILITY_START: RadrootsTagContract = tag( + "radroots:availability_start", + RadrootsTagCardinality::OptionalOne, + RadrootsTagSemantic::Status, + RadrootsTagValueType::UnixTimestamp, + false, +); +const TAG_OPERATIONAL_LISTING_EXPIRES_AT: RadrootsTagContract = tag( + "expires_at", + RadrootsTagCardinality::OptionalOne, + RadrootsTagSemantic::Status, + RadrootsTagValueType::UnixTimestamp, + false, +); +const TAG_OPERATIONAL_LISTING_DELIVERY: RadrootsTagContract = tag( + "delivery", + RadrootsTagCardinality::OptionalOne, + RadrootsTagSemantic::Reference, + RadrootsTagValueType::Text, + false, +); +const TAG_OPERATIONAL_LISTING_PROCESS: RadrootsTagContract = tag( + "process", + RadrootsTagCardinality::OptionalOne, + RadrootsTagSemantic::Category, + RadrootsTagValueType::Text, + false, +); +const TAG_OPERATIONAL_LISTING_LOT: RadrootsTagContract = tag( + "lot", + RadrootsTagCardinality::OptionalOne, + RadrootsTagSemantic::Reference, + RadrootsTagValueType::Text, + false, +); +const TAG_OPERATIONAL_LISTING_PROFILE: RadrootsTagContract = tag( + "profile", + RadrootsTagCardinality::OptionalOne, + RadrootsTagSemantic::Category, + RadrootsTagValueType::Text, + false, +); +const TAG_OPERATIONAL_LISTING_YEAR: RadrootsTagContract = tag( + "year", + RadrootsTagCardinality::OptionalOne, + RadrootsTagSemantic::Category, + RadrootsTagValueType::Text, false, ); const TAG_CALENDAR_IMAGE: RadrootsTagContract = tag( @@ -913,16 +1033,34 @@ const CALENDAR_RSVP_TAGS: &[RadrootsTagContract] = &[ TAG_CALENDAR_RSVP_AUTHOR, ]; const FARM_TAGS: &[RadrootsTagContract] = &[TAG_D, TAG_TITLE, TAG_LOCATION, TAG_IMAGE]; -const LISTING_TAGS: &[RadrootsTagContract] = &[ +const OPERATIONAL_LISTING_TAGS: &[RadrootsTagContract] = &[ TAG_D, - TAG_TITLE, + TAG_P_REQUIRED, + TAG_OPERATIONAL_LISTING_FARM, + TAG_OPERATIONAL_LISTING_PRODUCT_KEY, + TAG_OPERATIONAL_LISTING_TITLE, + TAG_OPERATIONAL_LISTING_CATEGORY, TAG_SUMMARY, TAG_PUBLISHED_AT, + TAG_OPERATIONAL_LISTING_PROCESS, + TAG_OPERATIONAL_LISTING_LOT, + TAG_OPERATIONAL_LISTING_PROFILE, + TAG_OPERATIONAL_LISTING_YEAR, TAG_LOCATION, TAG_PRICE, TAG_STATUS, - TAG_CATEGORY, TAG_IMAGE, + TAG_GEOHASH_OPTIONAL, + TAG_OPERATIONAL_LISTING_PRIMARY_BIN, + TAG_OPERATIONAL_LISTING_BIN, + TAG_OPERATIONAL_LISTING_PRICE, + TAG_OPERATIONAL_LISTING_DISCOUNT, + TAG_OPERATIONAL_LISTING_RESOURCE_AREA, + TAG_OPERATIONAL_LISTING_PLOT, + TAG_OPERATIONAL_LISTING_INVENTORY, + TAG_OPERATIONAL_LISTING_AVAILABILITY_START, + TAG_OPERATIONAL_LISTING_EXPIRES_AT, + TAG_OPERATIONAL_LISTING_DELIVERY, ]; const TRADE_MUTATION_TAGS: &[RadrootsTagContract] = &[TAG_CONTRACT_REQUIRED, TAG_D, TAG_P_REQUIRED, TAG_E_MANY]; @@ -971,14 +1109,14 @@ const PROFILE_REDUCERS: &[RadrootsReducer] = &[RadrootsReducer::ProfileProjectio const FARM_OPS_REDUCERS: &[RadrootsReducer] = &[RadrootsReducer::FarmOpsProjection]; const GROUP_REDUCERS: &[RadrootsReducer] = &[RadrootsReducer::GroupProjection]; const CALENDAR_REDUCERS: &[RadrootsReducer] = &[RadrootsReducer::CalendarProjection]; -const LISTING_REDUCERS: &[RadrootsReducer] = &[ - RadrootsReducer::ListingProjection, +const OPERATIONAL_LISTING_REDUCERS: &[RadrootsReducer] = &[ + RadrootsReducer::OperationalListingProjection, RadrootsReducer::MarketProjection, - RadrootsReducer::ListingInventoryAccounting, + RadrootsReducer::OperationalListingInventoryAccounting, ]; const TRADE_MUTATION_REDUCERS: &[RadrootsReducer] = &[ RadrootsReducer::TradeProjection, - RadrootsReducer::ListingInventoryAccounting, + RadrootsReducer::OperationalListingInventoryAccounting, ]; const TRADE_VALIDATION_REDUCERS: &[RadrootsReducer] = &[RadrootsReducer::TradeValidation]; const RELAY_REDUCERS: &[RadrootsReducer] = &[RadrootsReducer::NostrRelayPolicyProjection]; @@ -1737,12 +1875,12 @@ static ALL_KIND_CONTRACTS: &[RadrootsKindContract] = &[ ["radroots.farm.workspace_manifest.v1"] ), kind_contract!( - KIND_LISTING, - "KIND_LISTING", - "Listing", + KIND_CLASSIFIED_LISTING, + "KIND_CLASSIFIED_LISTING", + "Classified Listing", RadrootsEventClass::Addressable, - RadrootsNostrStandard::Radroots, - ["radroots.listing.published.v1"] + RadrootsNostrStandard::Nip99, + ["radroots.operational_listing.published.v1"] ), kind_contract!( KIND_KNOWLEDGE_SOURCE, @@ -2945,17 +3083,17 @@ static ALL_EVENT_CONTRACTS: &[RadrootsEventContract] = &[ FARM_OPS_REDUCERS ), event_contract!( - "radroots.listing.published.v1", - KIND_LISTING, - "Listing", - "RadrootsListing", + "radroots.operational_listing.published.v1", + KIND_CLASSIFIED_LISTING, + "Operational Listing", + "RadrootsOperationalListing", RadrootsEventClass::Addressable, RadrootsEventPrivacy::Public, RadrootsActorRole::Seller, - RadrootsContentSchema::JsonObject, + RadrootsContentSchema::Markdown, RadrootsEventDiscriminator::KindOnly, - LISTING_TAGS, - LISTING_REDUCERS + OPERATIONAL_LISTING_TAGS, + OPERATIONAL_LISTING_REDUCERS ), experimental_event_contract!( "radroots.knowledge.source.v1", @@ -3325,7 +3463,7 @@ pub fn kind_contract_family(contract: &RadrootsKindContract) -> Option<RadrootsC | KIND_RESOURCE_HARVEST_CAP | KIND_FARM_WORKSPACE_MANIFEST | KIND_FARM_CRDT_CHANGE => RadrootsContractFamily::Farm, - KIND_LISTING => RadrootsContractFamily::Market, + KIND_CLASSIFIED_LISTING => RadrootsContractFamily::Market, KIND_TRADE_VALIDATION_RECEIPT | KIND_TRADE_PROPOSAL | KIND_TRADE_DECISION @@ -3479,7 +3617,7 @@ fn contract_family_for_id(id: &str) -> Option<RadrootsContractFamily> { Some(RadrootsContractFamily::Knowledge) } else if id.starts_with("radroots.list.") || id.starts_with("radroots.list_set.") { Some(RadrootsContractFamily::List) - } else if id.starts_with("radroots.listing.") { + } else if id.starts_with("radroots.operational_listing.") { Some(RadrootsContractFamily::Market) } else if id.starts_with("radroots.message.") { Some(RadrootsContractFamily::Message) @@ -4962,6 +5100,49 @@ mod tests { } #[test] + fn classified_listing_kind_and_operational_profile_are_distinct() { + let kind = kind_contract(KIND_CLASSIFIED_LISTING).expect("classified listing kind"); + assert_eq!(kind.canonical_constant, "KIND_CLASSIFIED_LISTING"); + assert_eq!(kind.name, "Classified Listing"); + assert_eq!(kind.class, RadrootsEventClass::Addressable); + assert_eq!(kind.standard, RadrootsNostrStandard::Nip99); + assert_eq!( + kind.accepted_event_contracts, + &["radroots.operational_listing.published.v1"] + ); + + let profile = event_contract("radroots.operational_listing.published.v1") + .expect("operational listing profile"); + assert_eq!(profile.name, "Operational Listing"); + assert_eq!(profile.payload_type, "RadrootsOperationalListing"); + assert_eq!(profile.content_schema, RadrootsContentSchema::Markdown); + assert_eq!(profile.discriminator, RadrootsEventDiscriminator::KindOnly); + assert!(event_contract("radroots.listing.published.v1").is_none()); + + let seller = "a".repeat(64); + let tags = vec![ + owned_tag(&["d", "carrots"]), + owned_tag(&["p", seller.as_str()]), + owned_tag(&["a", format!("30340:{seller}:victoria-farm").as_str()]), + owned_tag(&["key", "carrots"]), + owned_tag(&["title", "Carrots"]), + owned_tag(&["category", "produce"]), + owned_tag(&["radroots:primary_bin", "field-bin"]), + owned_tag(&["radroots:bin", "field-bin", "1000", "g"]), + owned_tag(&["radroots:price", "field-bin", "3", "CAD", "1", "lb"]), + ]; + assert_eq!( + validate_event_contract_parts( + KIND_CLASSIFIED_LISTING, + &tags, + "# Carrots", + "radroots.operational_listing.published.v1", + ), + Ok(()) + ); + } + + #[test] fn event_contract_lookup_supports_many_contracts_per_kind() { let contracts = event_contracts_for_kind(KIND_LIST_SET_GENERIC).collect::<Vec<_>>(); assert_eq!(contracts.len(), 6); @@ -5048,7 +5229,7 @@ mod tests { Some(RadrootsContractFamily::List), ), ( - "radroots.listing.test.v1", + "radroots.operational_listing.test.v1", Some(RadrootsContractFamily::Market), ), ( @@ -5082,7 +5263,7 @@ mod tests { (KIND_LIST_SET_GENERIC, RadrootsContractFamily::List), (KIND_CALENDAR_EVENT_RSVP, RadrootsContractFamily::Calendar), (KIND_FARM_CRDT_CHANGE, RadrootsContractFamily::Farm), - (KIND_LISTING, RadrootsContractFamily::Market), + (KIND_CLASSIFIED_LISTING, RadrootsContractFamily::Market), (KIND_TRADE_CANCELLATION, RadrootsContractFamily::Trade), (KIND_KNOWLEDGE_CLAIM, RadrootsContractFamily::Knowledge), (KIND_JOB_FEEDBACK, RadrootsContractFamily::Job), diff --git a/crates/event/src/dto.rs b/crates/event/src/dto.rs @@ -82,7 +82,7 @@ mod tests { assert!(export_names.contains("RadrootsNip01EventWireDto")); assert!(export_names.contains("RadrootsSignedEventDto")); assert!(export_names.contains("RadrootsVerifiedSignedEventDto")); - assert!(export_names.contains("RadrootsListingImageSize")); + assert!(export_names.contains("RadrootsOperationalListingImageSize")); } #[test] @@ -90,7 +90,7 @@ mod tests { let registry = build_registry(dto_roots()); let summary = registry - .struct_field_presence("RadrootsListingProduct", "summary") + .struct_field_presence("RadrootsOperationalListingProduct", "summary") .expect("summary field exists"); assert!(!summary.required_on_deserialize); assert!(summary.nullable); @@ -121,6 +121,7 @@ mod tests { "RadrootsOrderDiscountOffer", "RadrootsOrderDiscountDecision", "RadrootsListingCancel", + "RadrootsOperationalListingCancel", "RadrootsOrderChange", "RadrootsOrderResponse", "RadrootsOrderRevision", @@ -144,7 +145,7 @@ mod tests { "RadrootsOrderDecision", "RadrootsOrderDecisionOutcome", "RadrootsOrderCancellation", - "RadrootsListingParseError", + "RadrootsOperationalListingParseError", ] { assert!( rust_names.contains(source_root), diff --git a/crates/event/src/generated/dto_roots.rs b/crates/event/src/generated/dto_roots.rs @@ -85,24 +85,40 @@ pub fn dto_bindgen_roots() -> ::std::vec::Vec<::dto_bindgen::export::RootDescrip ::dto_bindgen::export::RootDescriptor::new::<crate::list::RadrootsList>(), ::dto_bindgen::export::RootDescriptor::new::<crate::list::RadrootsListEntry>(), ::dto_bindgen::export::RootDescriptor::new::<crate::list_set::RadrootsListSet>(), - ::dto_bindgen::export::RootDescriptor::new::<crate::listing::RadrootsListing>(), - ::dto_bindgen::export::RootDescriptor::new::<crate::listing::RadrootsListingAvailability>(), - ::dto_bindgen::export::RootDescriptor::new::<crate::listing::RadrootsListingBin>(), - ::dto_bindgen::export::RootDescriptor::new::<crate::listing::RadrootsListingDeliveryMethod>( - ), - ::dto_bindgen::export::RootDescriptor::new::<crate::listing::RadrootsListingImage>(), - ::dto_bindgen::export::RootDescriptor::new::<crate::listing::RadrootsListingProduct>(), - ::dto_bindgen::export::RootDescriptor::new::<crate::listing::RadrootsListingPublicLocation>( - ), - ::dto_bindgen::export::RootDescriptor::new::<crate::listing::RadrootsListingStatus>(), ::dto_bindgen::export::RootDescriptor::new::<crate::message::RadrootsMessage>(), ::dto_bindgen::export::RootDescriptor::new::<crate::message::RadrootsMessageRecipient>(), ::dto_bindgen::export::RootDescriptor::new::<crate::message_file::RadrootsMessageFile>(), ::dto_bindgen::export::RootDescriptor::new::< crate::message_file::RadrootsMessageFileDimensions, >(), + ::dto_bindgen::export::RootDescriptor::new::< + crate::operational_listing::RadrootsOperationalListing, + >(), + ::dto_bindgen::export::RootDescriptor::new::< + crate::operational_listing::RadrootsOperationalListingAvailability, + >(), + ::dto_bindgen::export::RootDescriptor::new::< + crate::operational_listing::RadrootsOperationalListingBin, + >(), + ::dto_bindgen::export::RootDescriptor::new::< + crate::operational_listing::RadrootsOperationalListingDeliveryMethod, + >(), + ::dto_bindgen::export::RootDescriptor::new::< + crate::operational_listing::RadrootsOperationalListingImage, + >(), + ::dto_bindgen::export::RootDescriptor::new::< + crate::operational_listing::RadrootsOperationalListingParseError, + >(), + ::dto_bindgen::export::RootDescriptor::new::< + crate::operational_listing::RadrootsOperationalListingProduct, + >(), + ::dto_bindgen::export::RootDescriptor::new::< + crate::operational_listing::RadrootsOperationalListingPublicLocation, + >(), + ::dto_bindgen::export::RootDescriptor::new::< + crate::operational_listing::RadrootsOperationalListingStatus, + >(), ::dto_bindgen::export::RootDescriptor::new::<crate::order::RadrootsCommercialDomain>(), - ::dto_bindgen::export::RootDescriptor::new::<crate::order::RadrootsListingParseError>(), ::dto_bindgen::export::RootDescriptor::new::<crate::order::RadrootsOrderCancellation>(), ::dto_bindgen::export::RootDescriptor::new::<crate::order::RadrootsOrderDecision>(), ::dto_bindgen::export::RootDescriptor::new::<crate::order::RadrootsOrderDecisionOutcome>(), @@ -155,7 +171,7 @@ pub fn dto_bindgen_roots() -> ::std::vec::Vec<::dto_bindgen::export::RootDescrip >(), ::dto_bindgen::export::RootDescriptor::new::<crate::seal::RadrootsSeal>(), ::dto_bindgen::export::RootDescriptor::new::< - crate::trade_validation::RadrootsTradeValidationListingError, + crate::trade_validation::RadrootsOperationalListingValidationError, >(), ] } diff --git a/crates/event/src/ids.rs b/crates/event/src/ids.rs @@ -1,7 +1,7 @@ #![forbid(unsafe_code)] #[cfg(not(feature = "std"))] -use alloc::{string::String, string::ToString, vec::Vec}; +use alloc::{format, string::String, string::ToString, vec::Vec}; #[cfg(feature = "std")] use std::{string::String, vec::Vec}; @@ -9,12 +9,15 @@ use std::{string::String, vec::Vec}; use core::{borrow::Borrow, fmt, ops::Deref, str::FromStr}; use url_nostd::Url; +use crate::kinds::KIND_CLASSIFIED_LISTING; + #[derive(Clone, Debug, PartialEq, Eq)] pub enum RadrootsIdParseError { Empty, InvalidFormat, InvalidLength { expected: usize, actual: usize }, InvalidCharacter, + UnexpectedKind { expected: u32, actual: u32 }, TooLong { max: usize, actual: usize }, } @@ -30,6 +33,12 @@ impl fmt::Display for RadrootsIdParseError { ) } Self::InvalidCharacter => write!(f, "identifier contains an invalid character"), + Self::UnexpectedKind { expected, actual } => { + write!( + f, + "identifier kind {actual} does not match required kind {expected}" + ) + } Self::TooLong { max, actual } => { write!(f, "identifier length {actual} exceeds maximum length {max}") } @@ -185,7 +194,10 @@ validated_string_id!( RadrootsAddressableCoordinate, validate_addressable_coordinate ); -validated_string_id!(RadrootsListingAddress, validate_addressable_coordinate); +validated_string_id!( + RadrootsClassifiedListingAddress, + validate_classified_listing_address +); validated_string_id!(RadrootsOrderId, validate_commercial_id); validated_string_id!(RadrootsOrderQuoteId, validate_commercial_id); validated_string_id!(RadrootsInventoryBinId, validate_commercial_id); @@ -284,6 +296,20 @@ fn validate_addressable_coordinate(value: &str) -> Result<String, RadrootsIdPars Ok(value.to_string()) } +fn validate_classified_listing_address(value: &str) -> Result<String, RadrootsIdParseError> { + let parts = parse_addressable_coordinate_parts(value)?; + if parts.kind != KIND_CLASSIFIED_LISTING { + return Err(RadrootsIdParseError::UnexpectedKind { + expected: KIND_CLASSIFIED_LISTING, + actual: parts.kind, + }); + } + Ok(format!( + "{}:{}:{}", + KIND_CLASSIFIED_LISTING, parts.pubkey, parts.d_tag + )) +} + fn parse_addressable_coordinate_parts( value: &str, ) -> Result<RadrootsAddressableCoordinateParts, RadrootsIdParseError> { @@ -456,6 +482,10 @@ mod tests { actual: 7, }, RadrootsIdParseError::InvalidCharacter, + RadrootsIdParseError::UnexpectedKind { + expected: KIND_CLASSIFIED_LISTING, + actual: 30023, + }, RadrootsIdParseError::TooLong { max: 128, actual: 129, @@ -465,6 +495,14 @@ mod tests { for error in errors { assert!(!error.to_string().is_empty()); } + assert_eq!( + RadrootsIdParseError::UnexpectedKind { + expected: KIND_CLASSIFIED_LISTING, + actual: 30023, + } + .to_string(), + "identifier kind 30023 does not match required kind 30402" + ); } #[test] @@ -528,13 +566,33 @@ mod tests { addr ); assert_eq!( - RadrootsListingAddress::parse("30402:not_hex:listing-1").unwrap_err(), + RadrootsClassifiedListingAddress::parse("30402:not_hex:listing-1").unwrap_err(), RadrootsIdParseError::InvalidLength { expected: 64, actual: 7 } ); assert_eq!( + RadrootsClassifiedListingAddress::parse(format!("30023:{}:listing-1", hex_64('0'))) + .unwrap_err(), + RadrootsIdParseError::UnexpectedKind { + expected: KIND_CLASSIFIED_LISTING, + actual: 30023, + } + ); + let canonical_pubkey = hex_64('a'); + for noncanonical_kind in ["030402", "+30402"] { + assert_eq!( + RadrootsClassifiedListingAddress::parse(format!( + "{noncanonical_kind}:{}:listing-1", + hex_64('A') + )) + .expect("classified listing coordinate") + .as_str(), + format!("30402:{canonical_pubkey}:listing-1") + ); + } + assert_eq!( RadrootsAddressableCoordinate::parse("30402").unwrap_err(), RadrootsIdParseError::InvalidFormat ); @@ -617,7 +675,7 @@ mod tests { assert_identifier_impls!(RadrootsEventSignature, hex_128('c').as_str()); assert_identifier_impls!(RadrootsDTag, "listing-1"); assert_identifier_impls!(RadrootsAddressableCoordinate, addressable.as_str()); - assert_identifier_impls!(RadrootsListingAddress, addressable.as_str()); + assert_identifier_impls!(RadrootsClassifiedListingAddress, addressable.as_str()); assert_identifier_impls!(RadrootsOrderId, "order-1"); assert_identifier_impls!(RadrootsOrderQuoteId, "quote-1"); assert_identifier_impls!(RadrootsInventoryBinId, "bin-1"); @@ -800,8 +858,8 @@ mod tests { } #[allow(dead_code)] #[derive(Debug, serde::Deserialize)] - struct MissingListingAddress { - value: RadrootsListingAddress, + struct MissingClassifiedListingAddress { + value: RadrootsClassifiedListingAddress, } #[allow(dead_code)] #[derive(Debug, serde::Deserialize)] @@ -841,7 +899,10 @@ mod tests { assert_eq!(missing_field_message::<MissingTradeCandidateId>(), missing); assert_eq!(missing_field_message::<MissingTradeMutationId>(), missing); assert_eq!(missing_field_message::<MissingDTag>(), missing); - assert_eq!(missing_field_message::<MissingListingAddress>(), missing); + assert_eq!( + missing_field_message::<MissingClassifiedListingAddress>(), + missing + ); assert_eq!( missing_field_message::<MissingAddressableCoordinate>(), missing @@ -852,10 +913,9 @@ mod tests { let order: RadrootsOrderId = serde_json::from_value(serde_json::json!("order-1")).expect("order from value"); - let listing: RadrootsListingAddress = serde_json::from_value(serde_json::json!(format!( - "30402:{}:listing-1", - hex_64('a') - ))) + let listing: RadrootsClassifiedListingAddress = serde_json::from_value(serde_json::json!( + format!("30402:{}:listing-1", hex_64('a')) + )) .expect("listing from value"); let quote: RadrootsOrderQuoteId = serde_json::from_value(serde_json::json!("quote-1")).expect("quote from value"); diff --git a/crates/event/src/kinds.rs b/crates/event/src/kinds.rs @@ -76,7 +76,7 @@ pub const KIND_ACCOUNT_CLAIM: u32 = 30380; pub const KIND_VALIDATOR_SET: u32 = 30381; pub const KIND_APP_DATA: u32 = 30078; pub const KIND_FARM_WORKSPACE_MANIFEST: u32 = KIND_APP_DATA; -pub const KIND_LISTING: u32 = 30402; +pub const KIND_CLASSIFIED_LISTING: u32 = 30402; pub const KIND_APPLICATION_HANDLER: u32 = 31990; pub const KIND_GROUP_METADATA: u32 = 39000; pub const KIND_GROUP_ADMINS: u32 = 39001; @@ -103,7 +103,7 @@ pub const KIND_TRADE_SELLER_RESERVATION_ASSERTION: u32 = 3475; pub const KIND_KNOWLEDGE_SOURCE: u32 = 30450; pub const KIND_EVIDENCE_BOUNTY: u32 = 30451; -pub const LISTING_EVENT_KINDS: [u32; 1] = [KIND_LISTING]; +pub const CLASSIFIED_LISTING_EVENT_KINDS: [u32; 1] = [KIND_CLASSIFIED_LISTING]; pub const WIKI_EVENT_KINDS: [u32; 3] = [ KIND_WIKI_MERGE_REQUEST, @@ -176,7 +176,7 @@ pub const TRADE_MUTATION_EVENT_KINDS: [u32; 5] = [ pub const TRADE_VALIDATION_EVENT_KINDS: [u32; 1] = [KIND_TRADE_VALIDATION_RECEIPT]; pub const COMMERCIAL_EVENT_KINDS: [u32; 7] = [ - KIND_LISTING, + KIND_CLASSIFIED_LISTING, KIND_TRADE_PROPOSAL, KIND_TRADE_DECISION, KIND_TRADE_REVISION_PROPOSAL, @@ -196,7 +196,7 @@ pub const HOME_FEED_CANDIDATE_KINDS: [u32; 9] = [ KIND_REPOST, KIND_GENERIC_REPOST, KIND_ARTICLE, - KIND_LISTING, + KIND_CLASSIFIED_LISTING, KIND_CALENDAR_DATE_EVENT, KIND_CALENDAR_TIME_EVENT, KIND_FARM, @@ -210,11 +210,15 @@ pub const EVENTS_CANDIDATE_KINDS: [u32; 4] = [ KIND_CALENDAR_EVENT_RSVP, ]; -pub const MARKET_CANDIDATE_KINDS: [u32; 3] = [KIND_LISTING, KIND_FARM, KIND_PUBLIC_FILE_METADATA]; +pub const MARKET_CANDIDATE_KINDS: [u32; 3] = [ + KIND_CLASSIFIED_LISTING, + KIND_FARM, + KIND_PUBLIC_FILE_METADATA, +]; pub const MAP_CANDIDATE_KINDS: [u32; 7] = [ KIND_FARM, - KIND_LISTING, + KIND_CLASSIFIED_LISTING, KIND_CALENDAR_DATE_EVENT, KIND_CALENDAR_TIME_EVENT, KIND_POST, @@ -227,7 +231,7 @@ pub const PROFILE_PUBLIC_CONTENT_KINDS: [u32; 8] = [ KIND_REPOST, KIND_GENERIC_REPOST, KIND_ARTICLE, - KIND_LISTING, + KIND_CLASSIFIED_LISTING, KIND_CALENDAR_DATE_EVENT, KIND_CALENDAR_TIME_EVENT, KIND_PUBLIC_FILE_METADATA, @@ -313,13 +317,8 @@ pub const PRODUCTION_SOCIAL_KINDS: [u32; 4] = [ ]; #[inline] -pub const fn is_listing_kind(kind: u32) -> bool { - kind == KIND_LISTING -} - -#[inline] -pub const fn is_listing_event_kind(kind: u32) -> bool { - is_listing_kind(kind) +pub const fn is_classified_listing_kind(kind: u32) -> bool { + kind == KIND_CLASSIFIED_LISTING } #[inline] @@ -440,7 +439,7 @@ pub const fn is_home_feed_candidate_kind(kind: u32) -> bool { | KIND_REPOST | KIND_GENERIC_REPOST | KIND_ARTICLE - | KIND_LISTING + | KIND_CLASSIFIED_LISTING | KIND_CALENDAR_DATE_EVENT | KIND_CALENDAR_TIME_EVENT | KIND_FARM @@ -461,7 +460,10 @@ pub const fn is_events_candidate_kind(kind: u32) -> bool { #[inline] pub const fn is_market_candidate_kind(kind: u32) -> bool { - matches!(kind, KIND_LISTING | KIND_FARM | KIND_PUBLIC_FILE_METADATA) + matches!( + kind, + KIND_CLASSIFIED_LISTING | KIND_FARM | KIND_PUBLIC_FILE_METADATA + ) } #[inline] @@ -469,7 +471,7 @@ pub const fn is_map_candidate_kind(kind: u32) -> bool { matches!( kind, KIND_FARM - | KIND_LISTING + | KIND_CLASSIFIED_LISTING | KIND_CALENDAR_DATE_EVENT | KIND_CALENDAR_TIME_EVENT | KIND_POST @@ -486,7 +488,7 @@ pub const fn is_profile_public_content_kind(kind: u32) -> bool { | KIND_REPOST | KIND_GENERIC_REPOST | KIND_ARTICLE - | KIND_LISTING + | KIND_CLASSIFIED_LISTING | KIND_CALENDAR_DATE_EVENT | KIND_CALENDAR_TIME_EVENT | KIND_PUBLIC_FILE_METADATA @@ -575,7 +577,7 @@ pub const fn is_trade_validation_event_kind(kind: u32) -> bool { #[inline] pub const fn is_commercial_event_kind(kind: u32) -> bool { - is_listing_event_kind(kind) + is_classified_listing_kind(kind) || is_trade_mutation_event_kind(kind) || is_trade_validation_event_kind(kind) } @@ -748,7 +750,7 @@ mod tests { assert!(is_public_social_kind(KIND_CALENDAR)); assert!(is_public_social_kind(KIND_CALENDAR_EVENT_RSVP)); assert!(!is_public_social_kind(KIND_REPORT)); - assert!(!is_public_social_kind(KIND_LISTING)); + assert!(!is_public_social_kind(KIND_CLASSIFIED_LISTING)); assert!(!is_public_social_kind(KIND_LIST_READ_WRITE_RELAYS)); assert!(!is_public_social_kind(KIND_FARM_CRDT_CHANGE)); assert!(!is_public_social_kind(KIND_FARM_WORKSPACE_MANIFEST)); @@ -826,7 +828,7 @@ mod tests { assert!(is_home_feed_candidate_kind(KIND_REPOST)); assert!(is_home_feed_candidate_kind(KIND_GENERIC_REPOST)); assert!(is_home_feed_candidate_kind(KIND_ARTICLE)); - assert!(is_home_feed_candidate_kind(KIND_LISTING)); + assert!(is_home_feed_candidate_kind(KIND_CLASSIFIED_LISTING)); assert!(is_home_feed_candidate_kind(KIND_CALENDAR_DATE_EVENT)); assert!(is_home_feed_candidate_kind(KIND_CALENDAR_TIME_EVENT)); assert!(is_home_feed_candidate_kind(KIND_FARM)); @@ -843,13 +845,13 @@ mod tests { assert!(!is_events_candidate_kind(KIND_POST)); assert!(!is_events_candidate_kind(KIND_FARM_CRDT_CHANGE)); - assert!(is_market_candidate_kind(KIND_LISTING)); + assert!(is_market_candidate_kind(KIND_CLASSIFIED_LISTING)); assert!(is_market_candidate_kind(KIND_FARM)); assert!(is_market_candidate_kind(KIND_PUBLIC_FILE_METADATA)); assert!(!is_market_candidate_kind(KIND_REPORT)); assert!(is_map_candidate_kind(KIND_FARM)); - assert!(is_map_candidate_kind(KIND_LISTING)); + assert!(is_map_candidate_kind(KIND_CLASSIFIED_LISTING)); assert!(is_map_candidate_kind(KIND_CALENDAR_DATE_EVENT)); assert!(is_map_candidate_kind(KIND_CALENDAR_TIME_EVENT)); assert!(is_map_candidate_kind(KIND_POST)); @@ -861,7 +863,7 @@ mod tests { assert!(is_profile_public_content_kind(KIND_REPOST)); assert!(is_profile_public_content_kind(KIND_GENERIC_REPOST)); assert!(is_profile_public_content_kind(KIND_ARTICLE)); - assert!(is_profile_public_content_kind(KIND_LISTING)); + assert!(is_profile_public_content_kind(KIND_CLASSIFIED_LISTING)); assert!(is_profile_public_content_kind(KIND_CALENDAR_DATE_EVENT)); assert!(is_profile_public_content_kind(KIND_CALENDAR_TIME_EVENT)); assert!(is_profile_public_content_kind(KIND_PUBLIC_FILE_METADATA)); @@ -911,7 +913,7 @@ mod tests { #[test] fn classifies_commercial_event_kinds() { - assert_eq!(LISTING_EVENT_KINDS, [KIND_LISTING]); + assert_eq!(CLASSIFIED_LISTING_EVENT_KINDS, [KIND_CLASSIFIED_LISTING]); assert_eq!( ORDER_EVENT_KINDS, [ @@ -937,7 +939,7 @@ mod tests { assert_eq!( COMMERCIAL_EVENT_KINDS, [ - KIND_LISTING, + KIND_CLASSIFIED_LISTING, KIND_TRADE_PROPOSAL, KIND_TRADE_DECISION, KIND_TRADE_REVISION_PROPOSAL, @@ -947,8 +949,8 @@ mod tests { ] ); - assert!(is_listing_event_kind(KIND_LISTING)); - assert!(!is_listing_event_kind(KIND_PROFILE)); + assert!(is_classified_listing_kind(KIND_CLASSIFIED_LISTING)); + assert!(!is_classified_listing_kind(KIND_PROFILE)); assert!(is_order_event_kind(KIND_ORDER_REQUEST)); assert!(is_order_event_kind(KIND_ORDER_DECISION)); @@ -989,7 +991,7 @@ mod tests { assert!(!is_trade_validation_event_kind(6322)); assert!(!is_trade_validation_event_kind(3434)); - assert!(is_commercial_event_kind(KIND_LISTING)); + assert!(is_commercial_event_kind(KIND_CLASSIFIED_LISTING)); assert!(is_commercial_event_kind(KIND_TRADE_PROPOSAL)); assert!(is_commercial_event_kind(KIND_TRADE_VALIDATION_RECEIPT)); assert!(!is_commercial_event_kind(KIND_ORDER_REQUEST)); diff --git a/crates/event/src/lib.rs b/crates/event/src/lib.rs @@ -43,11 +43,11 @@ pub mod kinds; pub mod knowledge; pub mod list; pub mod list_set; -pub mod listing; pub mod location; pub mod media; pub mod message; pub mod message_file; +pub mod operational_listing; pub mod order; pub mod order_economics; pub mod plot; diff --git a/crates/event/src/listing.rs b/crates/event/src/listing.rs @@ -1,269 +0,0 @@ -use radroots_core::{ - RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreMoney, RadrootsCoreQuantity, - RadrootsCoreQuantityPrice, RadrootsCoreUnit, -}; - -use crate::farm::RadrootsFarmRef; -use crate::ids::{RadrootsDTag, RadrootsInventoryBinId}; -use crate::plot::RadrootsPlotRef; -use crate::resource_area::RadrootsResourceAreaRef; - -#[cfg(not(feature = "std"))] -use alloc::{string::String, vec::Vec}; - -#[cfg_attr( - any(feature = "serde", test), - derive(serde::Serialize, serde::Deserialize) -)] -#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] -#[cfg_attr(feature = "dto-bindgen", dto(export))] -#[cfg_attr( - feature = "serde", - serde(rename_all = "snake_case", tag = "kind", content = "amount") -)] -#[derive(Clone, Debug)] -pub enum RadrootsListingAvailability { - Window { - #[cfg_attr(feature = "dto-bindgen", dto(int = "json_string"))] - start: Option<u64>, - #[cfg_attr(feature = "dto-bindgen", dto(int = "json_string"))] - end: Option<u64>, - }, - Status { - status: RadrootsListingStatus, - }, -} - -#[cfg_attr( - any(feature = "serde", test), - derive(serde::Serialize, serde::Deserialize) -)] -#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] -#[cfg_attr(feature = "dto-bindgen", dto(export))] -#[cfg_attr( - feature = "serde", - serde(rename_all = "snake_case", tag = "kind", content = "amount") -)] -#[derive(Clone, Debug)] -pub enum RadrootsListingStatus { - Active, - Sold, - Other { value: String }, -} - -#[cfg_attr( - any(feature = "serde", test), - derive(serde::Serialize, serde::Deserialize) -)] -#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] -#[cfg_attr(feature = "dto-bindgen", dto(export))] -#[cfg_attr( - feature = "serde", - serde(rename_all = "snake_case", tag = "kind", content = "amount") -)] -#[derive(Clone, Debug)] -pub enum RadrootsListingDeliveryMethod { - Pickup, - LocalDelivery, - Shipping, - Other { method: String }, -} - -#[cfg_attr( - any(feature = "serde", test), - derive(serde::Serialize, serde::Deserialize) -)] -#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] -#[cfg_attr(feature = "dto-bindgen", dto(export))] -#[derive(Clone, Debug)] -pub struct RadrootsListing { - pub d_tag: RadrootsDTag, - #[cfg_attr(feature = "dto-bindgen", dto(int = "json_string"))] - #[cfg_attr( - feature = "serde", - serde(default, skip_serializing_if = "Option::is_none") - )] - pub published_at: Option<u64>, - #[cfg_attr(any(feature = "serde", test), serde(default))] - pub farm: RadrootsFarmRef, - pub product: RadrootsListingProduct, - pub primary_bin_id: RadrootsInventoryBinId, - pub bins: Vec<RadrootsListingBin>, - pub resource_area: Option<RadrootsResourceAreaRef>, - pub plot: Option<RadrootsPlotRef>, - pub discounts: Option<Vec<RadrootsCoreDiscount>>, - pub inventory_available: Option<RadrootsCoreDecimal>, - pub availability: Option<RadrootsListingAvailability>, - pub delivery_method: Option<RadrootsListingDeliveryMethod>, - pub location: Option<RadrootsListingPublicLocation>, - pub images: Option<Vec<RadrootsListingImage>>, -} - -#[cfg_attr( - any(feature = "serde", test), - derive(serde::Serialize, serde::Deserialize) -)] -#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] -#[cfg_attr(feature = "dto-bindgen", dto(export))] -#[derive(Clone, Debug)] -pub struct RadrootsListingProduct { - pub key: String, - pub title: String, - pub category: String, - pub summary: Option<String>, - pub process: Option<String>, - pub lot: Option<String>, - pub location: Option<String>, - pub profile: Option<String>, - pub year: Option<String>, -} - -pub const RADROOTS_LISTING_PRODUCT_TAG_KEYS: [&str; 9] = [ - "key", "title", "category", "summary", "process", "lot", "location", "profile", "year", -]; - -pub struct RadrootsListingProductTagKeys; - -#[cfg_attr( - any(feature = "serde", test), - derive(serde::Serialize, serde::Deserialize) -)] -#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] -#[cfg_attr(feature = "dto-bindgen", dto(export))] -#[derive(Clone, Debug)] -pub struct RadrootsListingBin { - pub bin_id: RadrootsInventoryBinId, - pub quantity: RadrootsCoreQuantity, - pub price_per_canonical_unit: RadrootsCoreQuantityPrice, - pub display_amount: Option<RadrootsCoreDecimal>, - pub display_unit: Option<RadrootsCoreUnit>, - pub display_label: Option<String>, - pub display_price: Option<RadrootsCoreMoney>, - pub display_price_unit: Option<RadrootsCoreUnit>, -} - -#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] -#[cfg_attr(feature = "dto-bindgen", dto(export))] -#[cfg_attr( - any(feature = "serde", test), - derive(serde::Serialize, serde::Deserialize) -)] -#[derive(Clone, Debug)] -pub struct RadrootsListingPublicLocation { - pub primary: String, - pub city: Option<String>, - pub region: Option<String>, - pub country: Option<String>, - pub geohash: String, -} - -#[cfg_attr( - any(feature = "serde", test), - derive(serde::Serialize, serde::Deserialize) -)] -#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] -#[cfg_attr(feature = "dto-bindgen", dto(export))] -#[derive(Clone, Debug)] -pub struct RadrootsListingImage { - pub url: String, - pub size: Option<RadrootsListingImageSize>, -} - -#[cfg_attr( - any(feature = "serde", test), - derive(serde::Serialize, serde::Deserialize) -)] -#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] -#[derive(Clone, Debug)] -pub struct RadrootsListingImageSize { - pub w: u32, - pub h: u32, -} - -#[cfg(all(test, feature = "std"))] -mod tests { - use crate::farm::RadrootsFarmRef; - - #[test] - fn defaults_listing_farm_ref_to_empty_values() { - let farm_ref = RadrootsFarmRef::default(); - assert!(farm_ref.pubkey.is_empty()); - assert!(farm_ref.d_tag.is_empty()); - } - - #[test] - fn listing_model_covers_published_metadata() { - use crate::kinds::{KIND_LISTING, is_listing_kind}; - - let listing = super::RadrootsListing { - d_tag: "listing-draft".parse().unwrap(), - published_at: Some(1_700_000_000), - farm: RadrootsFarmRef::default(), - product: super::RadrootsListingProduct { - key: "lettuce".to_string(), - title: "lettuce".to_string(), - category: "produce".to_string(), - summary: None, - process: None, - lot: None, - location: None, - profile: None, - year: None, - }, - primary_bin_id: "bin-1".parse().unwrap(), - bins: vec![], - resource_area: None, - plot: None, - discounts: None, - inventory_available: None, - availability: None, - delivery_method: None, - location: None, - images: None, - }; - - assert_eq!(listing.published_at, Some(1_700_000_000)); - assert!(is_listing_kind(KIND_LISTING)); - } - - #[test] - #[cfg(feature = "serde")] - fn listing_deserializes_missing_farm_to_default_ref() { - let listing = super::RadrootsListing { - d_tag: "listing-draft".parse().unwrap(), - published_at: Some(1_700_000_000), - farm: RadrootsFarmRef { - pubkey: "farm-pubkey".to_string(), - d_tag: "farm-d-tag".to_string(), - }, - product: super::RadrootsListingProduct { - key: "lettuce".to_string(), - title: "lettuce".to_string(), - category: "produce".to_string(), - summary: None, - process: None, - lot: None, - location: None, - profile: None, - year: None, - }, - primary_bin_id: "bin-1".parse().unwrap(), - bins: vec![], - resource_area: None, - plot: None, - discounts: None, - inventory_available: None, - availability: None, - delivery_method: None, - location: None, - images: None, - }; - let mut json = serde_json::to_value(&listing).unwrap(); - json.as_object_mut().unwrap().remove("farm"); - - let parsed: super::RadrootsListing = serde_json::from_value(json).unwrap(); - - assert!(parsed.farm.pubkey.is_empty()); - assert!(parsed.farm.d_tag.is_empty()); - } -} diff --git a/crates/event/src/operational_listing.rs b/crates/event/src/operational_listing.rs @@ -0,0 +1,305 @@ +use radroots_core::{ + RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreMoney, RadrootsCoreQuantity, + RadrootsCoreQuantityPrice, RadrootsCoreUnit, +}; + +use crate::farm::RadrootsFarmRef; +use crate::ids::{RadrootsDTag, RadrootsInventoryBinId}; +use crate::plot::RadrootsPlotRef; +use crate::resource_area::RadrootsResourceAreaRef; + +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; + +#[cfg_attr( + any(feature = "serde", test), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[cfg_attr(feature = "dto-bindgen", dto(export))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RadrootsOperationalListingParseError { + InvalidKind(u32), + MissingTag(String), + InvalidTag(String), + InvalidNumber(String), + InvalidUnit, + InvalidCurrency, + InvalidJson(String), + InvalidDiscount(String), +} + +impl core::fmt::Display for RadrootsOperationalListingParseError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::InvalidKind(kind) => write!(f, "invalid operational listing kind: {kind}"), + Self::MissingTag(tag) => write!(f, "missing required tag: {tag}"), + Self::InvalidTag(tag) => write!(f, "invalid tag: {tag}"), + Self::InvalidNumber(field) => write!(f, "invalid number: {field}"), + Self::InvalidUnit => write!(f, "invalid unit"), + Self::InvalidCurrency => write!(f, "invalid currency"), + Self::InvalidJson(field) => write!(f, "invalid json: {field}"), + Self::InvalidDiscount(kind) => write!(f, "invalid discount data for {kind}"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for RadrootsOperationalListingParseError {} + +#[cfg_attr( + any(feature = "serde", test), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[cfg_attr(feature = "dto-bindgen", dto(export))] +#[cfg_attr( + feature = "serde", + serde(rename_all = "snake_case", tag = "kind", content = "amount") +)] +#[derive(Clone, Debug)] +pub enum RadrootsOperationalListingAvailability { + Window { + #[cfg_attr(feature = "dto-bindgen", dto(int = "json_string"))] + start: Option<u64>, + #[cfg_attr(feature = "dto-bindgen", dto(int = "json_string"))] + end: Option<u64>, + }, + Status { + status: RadrootsOperationalListingStatus, + }, +} + +#[cfg_attr( + any(feature = "serde", test), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[cfg_attr(feature = "dto-bindgen", dto(export))] +#[cfg_attr( + feature = "serde", + serde(rename_all = "snake_case", tag = "kind", content = "amount") +)] +#[derive(Clone, Debug)] +pub enum RadrootsOperationalListingStatus { + Active, + Sold, + Other { value: String }, +} + +#[cfg_attr( + any(feature = "serde", test), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[cfg_attr(feature = "dto-bindgen", dto(export))] +#[cfg_attr( + feature = "serde", + serde(rename_all = "snake_case", tag = "kind", content = "amount") +)] +#[derive(Clone, Debug)] +pub enum RadrootsOperationalListingDeliveryMethod { + Pickup, + LocalDelivery, + Shipping, + Other { method: String }, +} + +#[cfg_attr( + any(feature = "serde", test), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[cfg_attr(feature = "dto-bindgen", dto(export))] +#[derive(Clone, Debug)] +pub struct RadrootsOperationalListing { + pub d_tag: RadrootsDTag, + #[cfg_attr(feature = "dto-bindgen", dto(int = "json_string"))] + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub published_at: Option<u64>, + #[cfg_attr(any(feature = "serde", test), serde(default))] + pub farm: RadrootsFarmRef, + pub product: RadrootsOperationalListingProduct, + pub primary_bin_id: RadrootsInventoryBinId, + pub bins: Vec<RadrootsOperationalListingBin>, + pub resource_area: Option<RadrootsResourceAreaRef>, + pub plot: Option<RadrootsPlotRef>, + pub discounts: Option<Vec<RadrootsCoreDiscount>>, + pub inventory_available: Option<RadrootsCoreDecimal>, + pub availability: Option<RadrootsOperationalListingAvailability>, + pub delivery_method: Option<RadrootsOperationalListingDeliveryMethod>, + pub location: Option<RadrootsOperationalListingPublicLocation>, + pub images: Option<Vec<RadrootsOperationalListingImage>>, +} + +#[cfg_attr( + any(feature = "serde", test), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[cfg_attr(feature = "dto-bindgen", dto(export))] +#[derive(Clone, Debug)] +pub struct RadrootsOperationalListingProduct { + pub key: String, + pub title: String, + pub category: String, + pub summary: Option<String>, + pub process: Option<String>, + pub lot: Option<String>, + pub location: Option<String>, + pub profile: Option<String>, + pub year: Option<String>, +} + +pub const RADROOTS_OPERATIONAL_LISTING_PRODUCT_TAG_KEYS: [&str; 9] = [ + "key", "title", "category", "summary", "process", "lot", "location", "profile", "year", +]; + +pub struct RadrootsOperationalListingProductTagKeys; + +#[cfg_attr( + any(feature = "serde", test), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[cfg_attr(feature = "dto-bindgen", dto(export))] +#[derive(Clone, Debug)] +pub struct RadrootsOperationalListingBin { + pub bin_id: RadrootsInventoryBinId, + pub quantity: RadrootsCoreQuantity, + pub price_per_canonical_unit: RadrootsCoreQuantityPrice, + pub display_amount: Option<RadrootsCoreDecimal>, + pub display_unit: Option<RadrootsCoreUnit>, + pub display_label: Option<String>, + pub display_price: Option<RadrootsCoreMoney>, + pub display_price_unit: Option<RadrootsCoreUnit>, +} + +#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[cfg_attr(feature = "dto-bindgen", dto(export))] +#[cfg_attr( + any(feature = "serde", test), + derive(serde::Serialize, serde::Deserialize) +)] +#[derive(Clone, Debug)] +pub struct RadrootsOperationalListingPublicLocation { + pub primary: String, + pub city: Option<String>, + pub region: Option<String>, + pub country: Option<String>, + pub geohash: String, +} + +#[cfg_attr( + any(feature = "serde", test), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[cfg_attr(feature = "dto-bindgen", dto(export))] +#[derive(Clone, Debug)] +pub struct RadrootsOperationalListingImage { + pub url: String, + pub size: Option<RadrootsOperationalListingImageSize>, +} + +#[cfg_attr( + any(feature = "serde", test), + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[derive(Clone, Debug)] +pub struct RadrootsOperationalListingImageSize { + pub w: u32, + pub h: u32, +} + +#[cfg(all(test, feature = "std"))] +mod tests { + use crate::farm::RadrootsFarmRef; + + #[test] + fn defaults_listing_farm_ref_to_empty_values() { + let farm_ref = RadrootsFarmRef::default(); + assert!(farm_ref.pubkey.is_empty()); + assert!(farm_ref.d_tag.is_empty()); + } + + #[test] + fn listing_model_covers_published_metadata() { + use crate::kinds::{KIND_CLASSIFIED_LISTING, is_classified_listing_kind}; + + let listing = super::RadrootsOperationalListing { + d_tag: "listing-draft".parse().unwrap(), + published_at: Some(1_700_000_000), + farm: RadrootsFarmRef::default(), + product: super::RadrootsOperationalListingProduct { + key: "lettuce".to_string(), + title: "lettuce".to_string(), + category: "produce".to_string(), + summary: None, + process: None, + lot: None, + location: None, + profile: None, + year: None, + }, + primary_bin_id: "bin-1".parse().unwrap(), + bins: vec![], + resource_area: None, + plot: None, + discounts: None, + inventory_available: None, + availability: None, + delivery_method: None, + location: None, + images: None, + }; + + assert_eq!(listing.published_at, Some(1_700_000_000)); + assert!(is_classified_listing_kind(KIND_CLASSIFIED_LISTING)); + } + + #[test] + #[cfg(feature = "serde")] + fn listing_deserializes_missing_farm_to_default_ref() { + let listing = super::RadrootsOperationalListing { + d_tag: "listing-draft".parse().unwrap(), + published_at: Some(1_700_000_000), + farm: RadrootsFarmRef { + pubkey: "farm-pubkey".to_string(), + d_tag: "farm-d-tag".to_string(), + }, + product: super::RadrootsOperationalListingProduct { + key: "lettuce".to_string(), + title: "lettuce".to_string(), + category: "produce".to_string(), + summary: None, + process: None, + lot: None, + location: None, + profile: None, + year: None, + }, + primary_bin_id: "bin-1".parse().unwrap(), + bins: vec![], + resource_area: None, + plot: None, + discounts: None, + inventory_available: None, + availability: None, + delivery_method: None, + location: None, + images: None, + }; + let mut json = serde_json::to_value(&listing).unwrap(); + json.as_object_mut().unwrap().remove("farm"); + + let parsed: super::RadrootsOperationalListing = serde_json::from_value(json).unwrap(); + + assert!(parsed.farm.pubkey.is_empty()); + assert!(parsed.farm.d_tag.is_empty()); + } +} diff --git a/crates/event/src/order.rs b/crates/event/src/order.rs @@ -9,53 +9,19 @@ use alloc::{ #[cfg(test)] use crate::ids::RadrootsOrderQuoteId; use crate::ids::{ - RadrootsInventoryBinId, RadrootsListingAddress, RadrootsOrderId, RadrootsPublicKey, + RadrootsClassifiedListingAddress, RadrootsInventoryBinId, RadrootsOrderId, RadrootsPublicKey, }; use crate::kinds::*; +#[cfg(test)] +use crate::operational_listing::RadrootsOperationalListingParseError; pub use crate::order_economics::*; #[cfg(test)] -use crate::trade_validation::RadrootsTradeValidationListingError; +use crate::trade_validation::RadrootsOperationalListingValidationError; use radroots_core::{RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney}; pub const RADROOTS_COMMERCIAL_LISTING_DOMAIN: &str = "trade:listing"; pub const RADROOTS_ORDER_ENVELOPE_VERSION: u16 = 1; -#[cfg_attr( - any(feature = "serde", test), - derive(serde::Serialize, serde::Deserialize) -)] -#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] -#[cfg_attr(feature = "dto-bindgen", dto(export))] -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum RadrootsListingParseError { - InvalidKind(u32), - MissingTag(String), - InvalidTag(String), - InvalidNumber(String), - InvalidUnit, - InvalidCurrency, - InvalidJson(String), - InvalidDiscount(String), -} - -impl core::fmt::Display for RadrootsListingParseError { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - match self { - Self::InvalidKind(kind) => write!(f, "invalid listing kind: {kind}"), - Self::MissingTag(tag) => write!(f, "missing required tag: {tag}"), - Self::InvalidTag(tag) => write!(f, "invalid tag: {tag}"), - Self::InvalidNumber(field) => write!(f, "invalid number: {field}"), - Self::InvalidUnit => write!(f, "invalid unit"), - Self::InvalidCurrency => write!(f, "invalid currency"), - Self::InvalidJson(field) => write!(f, "invalid json: {field}"), - Self::InvalidDiscount(kind) => write!(f, "invalid discount data for {kind}"), - } - } -} - -#[cfg(feature = "std")] -impl std::error::Error for RadrootsListingParseError {} - impl RadrootsOrderEconomics { pub fn canonicalize(&mut self) { self.items @@ -175,7 +141,7 @@ impl RadrootsOrderEconomics { #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsOrderRequest { pub order_id: RadrootsOrderId, - pub listing_addr: RadrootsListingAddress, + pub listing_addr: RadrootsClassifiedListingAddress, pub buyer_pubkey: RadrootsPublicKey, pub seller_pubkey: RadrootsPublicKey, pub items: Vec<RadrootsOrderItem>, @@ -255,7 +221,7 @@ impl RadrootsOrderDecisionOutcome { #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsOrderDecision { pub order_id: RadrootsOrderId, - pub listing_addr: RadrootsListingAddress, + pub listing_addr: RadrootsClassifiedListingAddress, pub buyer_pubkey: RadrootsPublicKey, pub seller_pubkey: RadrootsPublicKey, pub decision: RadrootsOrderDecisionOutcome, @@ -281,7 +247,7 @@ impl RadrootsOrderDecision { #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsOrderCancellation { pub order_id: RadrootsOrderId, - pub listing_addr: RadrootsListingAddress, + pub listing_addr: RadrootsClassifiedListingAddress, pub buyer_pubkey: RadrootsPublicKey, pub seller_pubkey: RadrootsPublicKey, pub reason: String, @@ -810,7 +776,7 @@ mod tests { pubkey('a') } - fn sample_listing_addr() -> RadrootsListingAddress { + fn sample_listing_addr() -> RadrootsClassifiedListingAddress { format!("30402:{}:AAAAAAAAAAAAAAAAAAAAAg", seller_pubkey()) .parse() .unwrap() @@ -1071,23 +1037,29 @@ mod tests { #[test] fn listing_parse_error_json_preserves_external_tagged_shape() { assert_eq!( - serde_json::to_value(RadrootsListingParseError::InvalidKind(KIND_PROFILE)).unwrap(), + serde_json::to_value(RadrootsOperationalListingParseError::InvalidKind( + KIND_PROFILE + )) + .unwrap(), serde_json::json!({ "InvalidKind": KIND_PROFILE }) ); assert_eq!( - serde_json::to_value(RadrootsListingParseError::MissingTag("price".into())).unwrap(), + serde_json::to_value(RadrootsOperationalListingParseError::MissingTag( + "price".into() + )) + .unwrap(), serde_json::json!({ "MissingTag": "price" }) ); assert_eq!( - serde_json::to_value(RadrootsListingParseError::InvalidUnit).unwrap(), + serde_json::to_value(RadrootsOperationalListingParseError::InvalidUnit).unwrap(), serde_json::json!("InvalidUnit") ); assert_eq!( - serde_json::from_value::<RadrootsListingParseError>(serde_json::json!({ + serde_json::from_value::<RadrootsOperationalListingParseError>(serde_json::json!({ "InvalidJson": "bins" })) .unwrap(), - RadrootsListingParseError::InvalidJson("bins".into()) + RadrootsOperationalListingParseError::InvalidJson("bins".into()) ); } @@ -1571,35 +1543,35 @@ mod tests { #[test] fn listing_parse_error_display_variants() { assert_eq!( - RadrootsListingParseError::InvalidKind(KIND_PROFILE).to_string(), - "invalid listing kind: 0" + RadrootsOperationalListingParseError::InvalidKind(KIND_PROFILE).to_string(), + "invalid operational listing kind: 0" ); assert_eq!( - RadrootsListingParseError::MissingTag("price".into()).to_string(), + RadrootsOperationalListingParseError::MissingTag("price".into()).to_string(), "missing required tag: price" ); assert_eq!( - RadrootsListingParseError::InvalidTag("farm".into()).to_string(), + RadrootsOperationalListingParseError::InvalidTag("farm".into()).to_string(), "invalid tag: farm" ); assert_eq!( - RadrootsListingParseError::InvalidNumber("inventory".into()).to_string(), + RadrootsOperationalListingParseError::InvalidNumber("inventory".into()).to_string(), "invalid number: inventory" ); assert_eq!( - RadrootsListingParseError::InvalidUnit.to_string(), + RadrootsOperationalListingParseError::InvalidUnit.to_string(), "invalid unit" ); assert_eq!( - RadrootsListingParseError::InvalidCurrency.to_string(), + RadrootsOperationalListingParseError::InvalidCurrency.to_string(), "invalid currency" ); assert_eq!( - RadrootsListingParseError::InvalidJson("bins".into()).to_string(), + RadrootsOperationalListingParseError::InvalidJson("bins".into()).to_string(), "invalid json: bins" ); assert_eq!( - RadrootsListingParseError::InvalidDiscount("offer".into()).to_string(), + RadrootsOperationalListingParseError::InvalidDiscount("offer".into()).to_string(), "invalid discount data for offer" ); } @@ -1607,96 +1579,97 @@ mod tests { #[test] fn listing_validation_error_display_variants() { assert_eq!( - (RadrootsTradeValidationListingError::InvalidKind { kind: KIND_PROFILE }).to_string(), + (RadrootsOperationalListingValidationError::InvalidKind { kind: KIND_PROFILE }) + .to_string(), "invalid listing kind: 0" ); assert_eq!( - RadrootsTradeValidationListingError::MissingListingId.to_string(), + RadrootsOperationalListingValidationError::MissingListingId.to_string(), "missing listing id" ); assert_eq!( - RadrootsTradeValidationListingError::ListingEventNotFound { + RadrootsOperationalListingValidationError::ListingEventNotFound { listing_addr: "listing-1".into(), } .to_string(), "listing event not found: listing-1" ); assert_eq!( - RadrootsTradeValidationListingError::ListingEventFetchFailed { + RadrootsOperationalListingValidationError::ListingEventFetchFailed { listing_addr: "listing-2".into(), } .to_string(), "listing event fetch failed: listing-2" ); assert_eq!( - RadrootsTradeValidationListingError::ParseError { - error: RadrootsListingParseError::InvalidJson("payload".into()), + RadrootsOperationalListingValidationError::ParseError { + error: RadrootsOperationalListingParseError::InvalidJson("payload".into()), } .to_string(), "invalid listing data: invalid json: payload" ); assert_eq!( - RadrootsTradeValidationListingError::InvalidSeller.to_string(), + RadrootsOperationalListingValidationError::InvalidSeller.to_string(), "listing author does not match farm pubkey" ); assert_eq!( - RadrootsTradeValidationListingError::MissingFarmProfile.to_string(), + RadrootsOperationalListingValidationError::MissingFarmProfile.to_string(), "missing farm profile" ); assert_eq!( - RadrootsTradeValidationListingError::MissingFarmRecord.to_string(), + RadrootsOperationalListingValidationError::MissingFarmRecord.to_string(), "missing farm record" ); assert_eq!( - RadrootsTradeValidationListingError::MissingTitle.to_string(), + RadrootsOperationalListingValidationError::MissingTitle.to_string(), "missing listing title" ); assert_eq!( - RadrootsTradeValidationListingError::MissingDescription.to_string(), + RadrootsOperationalListingValidationError::MissingDescription.to_string(), "missing listing description" ); assert_eq!( - RadrootsTradeValidationListingError::MissingProductType.to_string(), + RadrootsOperationalListingValidationError::MissingProductType.to_string(), "missing listing product type" ); assert_eq!( - RadrootsTradeValidationListingError::MissingBins.to_string(), + RadrootsOperationalListingValidationError::MissingBins.to_string(), "missing listing bins" ); assert_eq!( - RadrootsTradeValidationListingError::MissingPrimaryBin.to_string(), + RadrootsOperationalListingValidationError::MissingPrimaryBin.to_string(), "missing primary listing bin" ); assert_eq!( - RadrootsTradeValidationListingError::InvalidBin.to_string(), + RadrootsOperationalListingValidationError::InvalidBin.to_string(), "invalid listing bin" ); assert_eq!( - RadrootsTradeValidationListingError::MissingPrice.to_string(), + RadrootsOperationalListingValidationError::MissingPrice.to_string(), "missing listing price" ); assert_eq!( - RadrootsTradeValidationListingError::InvalidPrice.to_string(), + RadrootsOperationalListingValidationError::InvalidPrice.to_string(), "invalid listing price" ); assert_eq!( - RadrootsTradeValidationListingError::MissingInventory.to_string(), + RadrootsOperationalListingValidationError::MissingInventory.to_string(), "missing listing inventory" ); assert_eq!( - RadrootsTradeValidationListingError::InvalidInventory.to_string(), + RadrootsOperationalListingValidationError::InvalidInventory.to_string(), "invalid listing inventory" ); assert_eq!( - RadrootsTradeValidationListingError::MissingAvailability.to_string(), + RadrootsOperationalListingValidationError::MissingAvailability.to_string(), "missing listing availability" ); assert_eq!( - RadrootsTradeValidationListingError::MissingLocation.to_string(), + RadrootsOperationalListingValidationError::MissingLocation.to_string(), "missing listing location" ); assert_eq!( - RadrootsTradeValidationListingError::MissingDeliveryMethod.to_string(), + RadrootsOperationalListingValidationError::MissingDeliveryMethod.to_string(), "missing listing delivery method" ); } diff --git a/crates/event/src/trade.rs b/crates/event/src/trade.rs @@ -12,7 +12,7 @@ use std::{string::String, vec::Vec}; use core::fmt; use crate::ids::{ - RadrootsAddressableCoordinate, RadrootsDTag, RadrootsEventId, RadrootsIdParseError, + RadrootsClassifiedListingAddress, RadrootsDTag, RadrootsEventId, RadrootsIdParseError, RadrootsInventoryBinId, RadrootsPublicKey, RadrootsTradeCandidateId, RadrootsTradeId, RadrootsTradeMutationId, }; @@ -272,7 +272,7 @@ impl RadrootsTradeCandidateTermsV1 { #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] pub struct RadrootsTradeCandidateLineV1 { pub line_id: RadrootsDTag, - pub listing_addr: RadrootsAddressableCoordinate, + pub listing_addr: RadrootsClassifiedListingAddress, pub listing_event_id: RadrootsEventId, pub listing_snapshot_sha256: String, pub product_id: String, @@ -1116,7 +1116,7 @@ mod tests { farm_id: RadrootsDTag::parse("farm-1").unwrap(), lines: vec![RadrootsTradeCandidateLineV1 { line_id: RadrootsDTag::parse("line-1").unwrap(), - listing_addr: RadrootsAddressableCoordinate::parse(format!( + listing_addr: RadrootsClassifiedListingAddress::parse(format!( "30402:{}:listing-1", hex_64('b') )) @@ -1556,6 +1556,15 @@ mod tests { } #[test] + fn trade_candidate_deserialization_rejects_non_classified_listing_coordinates() { + let mut value = serde_json::to_value(candidate()).expect("candidate json"); + value["lines"][0]["listing_addr"] = + serde_json::json!(format!("30023:{}:listing-1", hex_64('b'))); + + assert!(serde_json::from_value::<RadrootsTradeCandidateTermsV1>(value).is_err()); + } + + #[test] #[cfg_attr(coverage_nightly, coverage(off))] fn trade_decision_and_reservation_contracts_cover_all_paths() { let assertion = reservation_assertion(); diff --git a/crates/event/src/trade_validation.rs b/crates/event/src/trade_validation.rs @@ -3,7 +3,7 @@ #[cfg(not(feature = "std"))] use alloc::string::String; -use crate::order::RadrootsListingParseError; +use crate::operational_listing::RadrootsOperationalListingParseError; #[cfg_attr( any(feature = "serde", test), @@ -16,12 +16,20 @@ use crate::order::RadrootsListingParseError; serde(rename_all = "snake_case", tag = "kind", content = "amount") )] #[derive(Clone, Debug, PartialEq, Eq)] -pub enum RadrootsTradeValidationListingError { - InvalidKind { kind: u32 }, +pub enum RadrootsOperationalListingValidationError { + InvalidKind { + kind: u32, + }, MissingListingId, - ListingEventNotFound { listing_addr: String }, - ListingEventFetchFailed { listing_addr: String }, - ParseError { error: RadrootsListingParseError }, + ListingEventNotFound { + listing_addr: String, + }, + ListingEventFetchFailed { + listing_addr: String, + }, + ParseError { + error: RadrootsOperationalListingParseError, + }, InvalidSeller, MissingFarmProfile, MissingFarmRecord, @@ -43,7 +51,7 @@ pub enum RadrootsTradeValidationListingError { MissingDeliveryMethod, } -impl core::fmt::Display for RadrootsTradeValidationListingError { +impl core::fmt::Display for RadrootsOperationalListingValidationError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { Self::InvalidKind { kind } => write!(f, "invalid listing kind: {kind}"), @@ -79,7 +87,7 @@ impl core::fmt::Display for RadrootsTradeValidationListingError { } #[cfg(feature = "std")] -impl std::error::Error for RadrootsTradeValidationListingError {} +impl std::error::Error for RadrootsOperationalListingValidationError {} #[cfg(test)] mod tests { @@ -88,19 +96,19 @@ mod tests { #[test] fn listing_validation_error_display_covers_location_variants() { assert_eq!( - RadrootsTradeValidationListingError::MissingLocation.to_string(), + RadrootsOperationalListingValidationError::MissingLocation.to_string(), "missing listing location" ); assert_eq!( - RadrootsTradeValidationListingError::MissingLocationLocality.to_string(), + RadrootsOperationalListingValidationError::MissingLocationLocality.to_string(), "missing listing location locality" ); assert_eq!( - RadrootsTradeValidationListingError::MissingLocationGeohash.to_string(), + RadrootsOperationalListingValidationError::MissingLocationGeohash.to_string(), "missing listing location geohash" ); assert_eq!( - RadrootsTradeValidationListingError::InvalidLocationGeohash.to_string(), + RadrootsOperationalListingValidationError::InvalidLocationGeohash.to_string(), "invalid listing location geohash" ); } diff --git a/crates/event_codec/src/farm/list_sets.rs b/crates/event_codec/src/farm/list_sets.rs @@ -8,10 +8,10 @@ use alloc::{ vec::Vec, }; -use radroots_event::kinds::KIND_LISTING; +use radroots_event::kinds::KIND_CLASSIFIED_LISTING; use radroots_event::list::RadrootsListEntry; use radroots_event::list_set::RadrootsListSet; -use radroots_event::listing::RadrootsListing; +use radroots_event::operational_listing::RadrootsOperationalListing; use radroots_event::plot::RadrootsPlot; use crate::d_tag::validate_d_tag; @@ -130,7 +130,7 @@ where }) } -pub fn farm_listings_list_set<I, S>( +pub fn farm_operational_listings_list_set<I, S>( farm_id: &str, farm_pubkey: &str, listing_ids: I, @@ -147,7 +147,7 @@ where } validate_d_tag(listing_id, "listing_id")?; let mut address = String::new(); - address.push_str(&KIND_LISTING.to_string()); + address.push_str(&KIND_CLASSIFIED_LISTING.to_string()); address.push(':'); address.push_str(farm_pubkey); address.push(':'); @@ -167,15 +167,15 @@ where }) } -pub fn farm_listings_list_set_from_listings<'a, I>( +pub fn farm_operational_listings_list_set_from_listings<'a, I>( farm_id: &str, farm_pubkey: &str, listings: I, ) -> Result<RadrootsListSet, EventEncodeError> where - I: IntoIterator<Item = &'a RadrootsListing>, + I: IntoIterator<Item = &'a RadrootsOperationalListing>, { - farm_listings_list_set( + farm_operational_listings_list_set( farm_id, farm_pubkey, listings.into_iter().map(|listing| listing.d_tag.as_str()), @@ -267,19 +267,21 @@ mod tests { farm_plots_list_set(farm_id, farm_pubkey, ["invalid"]).expect_err("invalid plot_id"); assert!(matches!(err, EventEncodeError::InvalidField("plot.d_tag"))); - let listings = farm_listings_list_set(farm_id, farm_pubkey, ["AAAAAAAAAAAAAAAAAAAAAA"]) - .expect("listings"); + let listings = + farm_operational_listings_list_set(farm_id, farm_pubkey, ["AAAAAAAAAAAAAAAAAAAAAA"]) + .expect("listings"); assert_eq!(listings.d_tag, "farm:AAAAAAAAAAAAAAAAAAAAAA:listings"); assert_eq!(listings.entries[0].tag, "a"); - let err = farm_listings_list_set("invalid", farm_pubkey, ["AAAAAAAAAAAAAAAAAAAAAA"]) - .expect_err("invalid farm id"); + let err = + farm_operational_listings_list_set("invalid", farm_pubkey, ["AAAAAAAAAAAAAAAAAAAAAA"]) + .expect_err("invalid farm id"); assert!(matches!(err, EventEncodeError::InvalidField("farm_id"))); - let err = farm_listings_list_set(farm_id, farm_pubkey, ["invalid"]) + let err = farm_operational_listings_list_set(farm_id, farm_pubkey, ["invalid"]) .expect_err("invalid listing_id"); assert!(matches!(err, EventEncodeError::InvalidField("listing_id"))); - let err = - farm_listings_list_set(farm_id, farm_pubkey, [" "]).expect_err("empty listing_id"); + let err = farm_operational_listings_list_set(farm_id, farm_pubkey, [" "]) + .expect_err("empty listing_id"); assert!(matches!( err, EventEncodeError::EmptyRequiredField("listing_id") diff --git a/crates/event_codec/src/farm/mod.rs b/crates/event_codec/src/farm/mod.rs @@ -11,7 +11,7 @@ mod tests { use crate::farm::decode::{farm_from_event, parsed_from_event}; use crate::farm::encode::{farm_build_tags, farm_ref_tags}; use crate::farm::list_sets::{ - farm_listings_list_set_from_listings, farm_members_list_set, + farm_members_list_set, farm_operational_listings_list_set_from_listings, farm_plots_list_set_from_plots, member_of_farms_list_set, }; use radroots_core::{ @@ -22,7 +22,10 @@ mod tests { use radroots_event::ids::{RadrootsDTag, RadrootsInventoryBinId}; #[cfg(feature = "serde_json")] use radroots_event::kinds::KIND_FARM; - use radroots_event::listing::{RadrootsListing, RadrootsListingBin, RadrootsListingProduct}; + use radroots_event::operational_listing::{ + RadrootsOperationalListing, RadrootsOperationalListingBin, + RadrootsOperationalListingProduct, + }; use radroots_event::plot::RadrootsPlot; #[cfg(feature = "serde_json")] @@ -475,14 +478,14 @@ mod tests { #[test] fn farm_listings_list_set_uses_listing_addresses() { - let listings = vec![RadrootsListing { + let listings = vec![RadrootsOperationalListing { d_tag: d_tag("AAAAAAAAAAAAAAAAAAAAAg"), published_at: None, farm: RadrootsFarmRef { pubkey: "farm_pubkey".to_string(), d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), }, - product: RadrootsListingProduct { + product: RadrootsOperationalListingProduct { key: "coffee".to_string(), title: "Coffee".to_string(), category: "coffee".to_string(), @@ -494,7 +497,7 @@ mod tests { year: None, }, primary_bin_id: bin_id("bin-1"), - bins: vec![RadrootsListingBin { + bins: vec![RadrootsOperationalListingBin { bin_id: bin_id("bin-1"), quantity: RadrootsCoreQuantity::new( RadrootsCoreDecimal::from(1u32), @@ -526,7 +529,7 @@ mod tests { images: None, }]; - let listings_list = farm_listings_list_set_from_listings( + let listings_list = farm_operational_listings_list_set_from_listings( "AAAAAAAAAAAAAAAAAAAAAA", "farm_pubkey", &listings, diff --git a/crates/event_codec/src/lib.rs b/crates/event_codec/src/lib.rs @@ -50,7 +50,7 @@ pub mod seal; pub mod list; pub mod list_set; -pub mod listing; +pub mod operational_listing; pub mod order; pub mod trade; diff --git a/crates/event_codec/src/listing/decode.rs b/crates/event_codec/src/listing/decode.rs @@ -1,848 +0,0 @@ -#[cfg(not(feature = "std"))] -use alloc::{ - string::{String, ToString}, - vec::Vec, -}; - -use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreMoney, - RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit, -}; -use radroots_event::{ - RadrootsEventEnvelope, - farm::RadrootsFarmRef, - ids::{RadrootsDTag, RadrootsInventoryBinId}, - kinds::{KIND_FARM, KIND_PLOT, KIND_RESOURCE_AREA, is_listing_kind}, - listing::{ - RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, - RadrootsListingDeliveryMethod, RadrootsListingImage, RadrootsListingImageSize, - RadrootsListingProduct, RadrootsListingPublicLocation, RadrootsListingStatus, - }, - location::is_public_geohash5, - order::RadrootsListingParseError, - plot::RadrootsPlotRef, - resource_area::RadrootsResourceAreaRef, - tags::{TAG_D, TAG_PUBLISHED_AT}, -}; - -use crate::d_tag::validate_d_tag_tag; -use crate::error::EventParseError; -use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent}; - -const EXPECTED_LISTING_KINDS: &str = "30402"; -const TAG_A: &str = "a"; -const TAG_P: &str = "p"; -const TAG_PRICE: &str = "price"; -const TAG_RADROOTS_BIN: &str = "radroots:bin"; -const TAG_RADROOTS_PRICE: &str = "radroots:price"; -const TAG_RADROOTS_DISCOUNT: &str = "radroots:discount"; -const TAG_RADROOTS_PRIMARY_BIN: &str = "radroots:primary_bin"; -const TAG_RADROOTS_RESOURCE_AREA: &str = "radroots:resource_area"; -const TAG_RADROOTS_PLOT: &str = "radroots:plot"; -const TAG_LOCATION: &str = "location"; -const TAG_IMAGE: &str = "image"; -const TAG_GEOHASH: &str = "g"; -const TAG_LABEL: &str = "l"; -const TAG_LABEL_NS: &str = "L"; -const TAG_DD: &str = "dd"; -const TAG_DD_LAT: &str = "dd.lat"; -const TAG_DD_LON: &str = "dd.lon"; -const TAG_INVENTORY: &str = "inventory"; -const TAG_DELIVERY: &str = "delivery"; -const TAG_RADROOTS_AVAILABILITY_START: &str = "radroots:availability_start"; -const TAG_STATUS: &str = "status"; -const TAG_EXPIRES_AT: &str = "expires_at"; - -#[derive(Clone, Debug, PartialEq, Eq)] -enum ListingDecodeError { - MissingTag(&'static str), - InvalidTag(&'static str), - InvalidNumber(&'static str), - InvalidUnit(&'static str), - InvalidCurrency(&'static str), - InvalidJson(&'static str), - #[cfg(feature = "serde_json")] - InvalidDiscount(&'static str), -} - -impl ListingDecodeError { - fn into_listing_parse_error(self) -> RadrootsListingParseError { - match self { - Self::MissingTag(field) => RadrootsListingParseError::MissingTag(field.to_string()), - Self::InvalidTag(field) => RadrootsListingParseError::InvalidTag(field.to_string()), - Self::InvalidNumber(field) => { - RadrootsListingParseError::InvalidNumber(field.to_string()) - } - Self::InvalidUnit(_) => RadrootsListingParseError::InvalidUnit, - Self::InvalidCurrency(_) => RadrootsListingParseError::InvalidCurrency, - Self::InvalidJson(field) => RadrootsListingParseError::InvalidJson(field.to_string()), - #[cfg(feature = "serde_json")] - Self::InvalidDiscount(field) => { - RadrootsListingParseError::InvalidDiscount(field.to_string()) - } - } - } - - fn into_event_parse_error(self) -> EventParseError { - match self { - Self::MissingTag(field) => EventParseError::MissingTag(field), - Self::InvalidTag(field) - | Self::InvalidNumber(field) - | Self::InvalidUnit(field) - | Self::InvalidCurrency(field) => EventParseError::InvalidTag(field), - #[cfg(feature = "serde_json")] - Self::InvalidDiscount(field) => EventParseError::InvalidTag(field), - Self::InvalidJson(_) => EventParseError::InvalidJson("content"), - } - } -} - -struct ListingLocationDraft { - primary: String, - city: Option<String>, - region: Option<String>, - country: Option<String>, -} - -fn parse_decimal( - value: &str, - field: &'static str, -) -> Result<RadrootsCoreDecimal, ListingDecodeError> { - value - .parse::<RadrootsCoreDecimal>() - .map_err(|_| ListingDecodeError::InvalidNumber(field)) -} - -fn reject_private_listing_location_content(content: &str) -> Result<(), ListingDecodeError> { - let trimmed = content.trim(); - if !trimmed.starts_with('{') { - return Ok(()); - } - #[cfg(feature = "serde_json")] - { - let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else { - return Ok(()); - }; - let Some(location) = value.get("location").and_then(|value| value.as_object()) else { - return Ok(()); - }; - for key in [ - "lat", - "lng", - "lon", - "point", - "polygon", - "coordinates", - "accuracy", - "altitude", - "label", - "tag_0", - "gcs", - ] { - if location.contains_key(key) { - return Err(ListingDecodeError::InvalidJson("location")); - } - } - } - Ok(()) -} - -fn parse_currency( - value: &str, - field: &'static str, -) -> Result<RadrootsCoreCurrency, ListingDecodeError> { - let upper = value.trim().to_ascii_uppercase(); - RadrootsCoreCurrency::from_str_upper(&upper) - .map_err(|_| ListingDecodeError::InvalidCurrency(field)) -} - -fn parse_unit(value: &str, field: &'static str) -> Result<RadrootsCoreUnit, ListingDecodeError> { - value - .parse::<RadrootsCoreUnit>() - .map_err(|_| ListingDecodeError::InvalidUnit(field)) -} - -fn parse_u64_tag_value( - value: Option<&String>, - field: &'static str, -) -> Result<u64, ListingDecodeError> { - value - .ok_or(ListingDecodeError::InvalidTag(field))? - .parse::<u64>() - .map_err(|_| ListingDecodeError::InvalidNumber(field)) -} - -fn parse_d_tag(tags: &[Vec<String>]) -> Result<String, ListingDecodeError> { - let tag = tags - .iter() - .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_D)) - .ok_or(ListingDecodeError::MissingTag(TAG_D))?; - let value = tag - .get(1) - .map(|value| value.to_string()) - .ok_or(ListingDecodeError::InvalidTag(TAG_D))?; - if value.trim().is_empty() { - return Err(ListingDecodeError::InvalidTag(TAG_D)); - } - validate_d_tag_tag(&value, TAG_D).map_err(|_| ListingDecodeError::InvalidTag(TAG_D))?; - Ok(value) -} - -fn parse_farm_ref(tags: &[Vec<String>]) -> Result<RadrootsFarmRef, ListingDecodeError> { - for tag in tags - .iter() - .filter(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_A)) - { - let value = tag - .get(1) - .map(|value| value.to_string()) - .ok_or(ListingDecodeError::InvalidTag(TAG_A))?; - let mut parts = value.splitn(3, ':'); - let kind = parts - .next() - .and_then(|raw| raw.parse::<u32>().ok()) - .ok_or(ListingDecodeError::InvalidTag(TAG_A))?; - if kind != KIND_FARM { - continue; - } - let pubkey = parts - .next() - .ok_or(ListingDecodeError::InvalidTag(TAG_A))? - .to_string(); - let d_tag = parts - .next() - .ok_or(ListingDecodeError::InvalidTag(TAG_A))? - .to_string(); - if pubkey.trim().is_empty() || d_tag.trim().is_empty() { - return Err(ListingDecodeError::InvalidTag(TAG_A)); - } - validate_d_tag_tag(&d_tag, TAG_A).map_err(|_| ListingDecodeError::InvalidTag(TAG_A))?; - return Ok(RadrootsFarmRef { pubkey, d_tag }); - } - Err(ListingDecodeError::MissingTag(TAG_A)) -} - -fn parse_farm_pubkey(tags: &[Vec<String>]) -> Result<String, ListingDecodeError> { - let tag = tags - .iter() - .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_P)) - .ok_or(ListingDecodeError::MissingTag(TAG_P))?; - let value = tag - .get(1) - .map(|value| value.to_string()) - .ok_or(ListingDecodeError::InvalidTag(TAG_P))?; - if value.trim().is_empty() { - return Err(ListingDecodeError::InvalidTag(TAG_P)); - } - Ok(value) -} - -fn parse_resource_area( - tags: &[Vec<String>], -) -> Result<Option<RadrootsResourceAreaRef>, ListingDecodeError> { - let tag = tags - .iter() - .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_RADROOTS_RESOURCE_AREA)); - let Some(tag) = tag else { - return Ok(None); - }; - let value = tag - .get(1) - .map(|value| value.to_string()) - .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA))?; - let mut parts = value.splitn(3, ':'); - let kind = parts - .next() - .and_then(|raw| raw.parse::<u32>().ok()) - .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA))?; - if kind != KIND_RESOURCE_AREA { - return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA)); - } - let pubkey = parts - .next() - .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA))? - .to_string(); - let d_tag = parts - .next() - .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA))? - .to_string(); - if pubkey.trim().is_empty() || d_tag.trim().is_empty() { - return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA)); - } - validate_d_tag_tag(&d_tag, TAG_RADROOTS_RESOURCE_AREA) - .map_err(|_| ListingDecodeError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA))?; - Ok(Some(RadrootsResourceAreaRef { pubkey, d_tag })) -} - -fn parse_plot_ref(tags: &[Vec<String>]) -> Result<Option<RadrootsPlotRef>, ListingDecodeError> { - let tag = tags - .iter() - .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_RADROOTS_PLOT)); - let Some(tag) = tag else { - return Ok(None); - }; - let value = tag - .get(1) - .map(|value| value.to_string()) - .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT))?; - let mut parts = value.splitn(3, ':'); - let kind = parts - .next() - .and_then(|raw| raw.parse::<u32>().ok()) - .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT))?; - if kind != KIND_PLOT { - return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT)); - } - let pubkey = parts - .next() - .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT))? - .to_string(); - let d_tag = parts - .next() - .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT))? - .to_string(); - if pubkey.trim().is_empty() || d_tag.trim().is_empty() { - return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT)); - } - validate_d_tag_tag(&d_tag, TAG_RADROOTS_PLOT) - .map_err(|_| ListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT))?; - Ok(Some(RadrootsPlotRef { pubkey, d_tag })) -} - -pub fn listing_from_event( - kind: u32, - tags: &[Vec<String>], - content: &str, -) -> Result<RadrootsListing, EventParseError> { - if !is_listing_kind(kind) { - return Err(EventParseError::InvalidKind { - expected: EXPECTED_LISTING_KINDS, - got: kind, - }); - } - decode_listing_from_event_parts(tags, content) - .map_err(ListingDecodeError::into_event_parse_error) -} - -pub fn listing_from_event_parts( - tags: &[Vec<String>], - content: &str, -) -> Result<RadrootsListing, RadrootsListingParseError> { - decode_listing_from_event_parts(tags, content) - .map_err(ListingDecodeError::into_listing_parse_error) -} - -pub fn listing_from_nostr_event( - event: &RadrootsEventEnvelope, -) -> Result<RadrootsListing, RadrootsListingParseError> { - if !is_listing_kind(event.kind_u32()) { - return Err(RadrootsListingParseError::InvalidKind(event.kind_u32())); - } - listing_from_event_parts(&event.tags_as_vec(), event.content()) -} - -fn decode_listing_from_event_parts( - tags: &[Vec<String>], - content: &str, -) -> Result<RadrootsListing, ListingDecodeError> { - reject_private_listing_location_content(content)?; - let d_tag = parse_d_tag(tags)?; - let farm_ref = parse_farm_ref(tags)?; - let farm_pubkey = parse_farm_pubkey(tags)?; - let resource_area = parse_resource_area(tags)?; - let plot = parse_plot_ref(tags)?; - - let mut product = RadrootsListingProduct { - key: String::new(), - title: String::new(), - category: String::new(), - summary: None, - process: None, - lot: None, - location: None, - profile: None, - year: None, - }; - let mut primary_bin_id: Option<String> = None; - let mut bin_drafts: Vec<BinDraft> = Vec::new(); - let mut bin_order = 0usize; - let mut discounts: Vec<RadrootsCoreDiscount> = Vec::new(); - let mut location: Option<ListingLocationDraft> = None; - let mut inventory_available: Option<RadrootsCoreDecimal> = None; - let mut availability_status: Option<RadrootsListingStatus> = None; - let mut availability_start: Option<u64> = None; - let mut availability_end: Option<u64> = None; - let mut delivery_method: Option<RadrootsListingDeliveryMethod> = None; - let mut images: Vec<RadrootsListingImage> = Vec::new(); - let mut geohash: Option<String> = None; - let mut published_at: Option<u64> = None; - - for tag in tags { - if tag.is_empty() { - continue; - } - match tag[0].as_str() { - "key" => set_if_empty(&mut product.key, tag.get(1)), - "title" => set_if_empty(&mut product.title, tag.get(1)), - "category" => set_if_empty(&mut product.category, tag.get(1)), - "summary" => set_optional(&mut product.summary, tag.get(1)), - TAG_PUBLISHED_AT => { - published_at = Some(parse_u64_tag_value(tag.get(1), TAG_PUBLISHED_AT)?); - } - "process" => set_optional(&mut product.process, tag.get(1)), - "lot" => set_optional(&mut product.lot, tag.get(1)), - "location" => { - let parse_structured_location = !matches!(tag.len(), 0..=2); - if parse_structured_location { - let primary = tag - .get(1) - .and_then(|value| clean_value(value)) - .ok_or(ListingDecodeError::InvalidTag(TAG_LOCATION))?; - let mut parsed = ListingLocationDraft { - primary, - city: None, - region: None, - country: None, - }; - if let Some(city) = tag.get(2).and_then(|value| clean_value(value)) { - parsed.city = Some(city); - } - if let Some(region) = tag.get(3).and_then(|value| clean_value(value)) { - parsed.region = Some(region); - } - if let Some(country) = tag.get(4).and_then(|value| clean_value(value)) { - parsed.country = Some(country); - } - location = Some(parsed); - } else { - set_optional(&mut product.location, tag.get(1)); - } - } - "profile" => set_optional(&mut product.profile, tag.get(1)), - "year" => set_optional(&mut product.year, tag.get(1)), - TAG_PRICE => {} - TAG_RADROOTS_PRIMARY_BIN => { - let value = tag - .get(1) - .and_then(|value| clean_value(value)) - .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN))?; - if let Some(existing) = primary_bin_id.as_ref() { - if existing != &value { - return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN)); - } - } else { - primary_bin_id = Some(value); - } - } - TAG_RADROOTS_BIN => { - if tag.len() < 4 || tag.len() > 7 { - return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_BIN)); - } - let bin_id = tag - .get(1) - .and_then(|value| clean_value(value)) - .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_BIN))?; - let amount = parse_decimal(&tag[2], TAG_RADROOTS_BIN)?; - let unit = parse_unit(&tag[3], TAG_RADROOTS_BIN)?; - if unit != unit.canonical_unit() { - return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_BIN)); - } - let bin = upsert_bin(&mut bin_drafts, &bin_id, &mut bin_order); - if bin.quantity.is_some() { - return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_BIN)); - } - bin.quantity = Some(RadrootsCoreQuantity::new(amount, unit)); - - match tag.as_slice() { - [_, _, _, _, display_amount_raw, display_unit_raw] - | [_, _, _, _, display_amount_raw, display_unit_raw, _] => { - let display_amount = parse_decimal(display_amount_raw, TAG_RADROOTS_BIN)?; - let display_unit = parse_unit(display_unit_raw, TAG_RADROOTS_BIN)?; - bin.display_amount = Some(display_amount); - bin.display_unit = Some(display_unit); - if let [_, _, _, _, _, _, label] = tag.as_slice() { - bin.display_label = clean_value(label); - } - } - [_, _, _, _, _] => { - return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_BIN)); - } - _ => {} - } - } - TAG_RADROOTS_PRICE => { - if tag.len() < 6 || tag.len() > 8 { - return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_PRICE)); - } - let bin_id = tag - .get(1) - .and_then(|value| clean_value(value)) - .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_PRICE))?; - let amount = parse_decimal(&tag[2], TAG_RADROOTS_PRICE)?; - let currency = parse_currency(&tag[3], TAG_RADROOTS_PRICE)?; - let per_amount = parse_decimal(&tag[4], TAG_RADROOTS_PRICE)?; - let per_unit = parse_unit(&tag[5], TAG_RADROOTS_PRICE)?; - let price_per_canonical_unit = RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new(amount, currency), - RadrootsCoreQuantity::new(per_amount, per_unit), - ); - if !price_per_canonical_unit.is_price_per_canonical_unit() { - return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_PRICE)); - } - let bin = upsert_bin(&mut bin_drafts, &bin_id, &mut bin_order); - if bin.price_per_canonical_unit.is_some() { - return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_PRICE)); - } - bin.price_per_canonical_unit = Some(price_per_canonical_unit); - - match tag.as_slice() { - [_, _, _, _, _, _, _] => { - return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_PRICE)); - } - [_, _, _, _, _, _, display_price_raw, display_unit_raw] => { - let display_price = parse_decimal(display_price_raw, TAG_RADROOTS_PRICE)?; - let display_unit = parse_unit(display_unit_raw, TAG_RADROOTS_PRICE)?; - bin.display_price = Some(RadrootsCoreMoney::new(display_price, currency)); - bin.display_price_unit = Some(display_unit); - } - _ => {} - } - } - TAG_RADROOTS_DISCOUNT => { - let payload = tag - .get(1) - .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_DISCOUNT))?; - discounts.push(parse_discount(payload)?); - } - TAG_GEOHASH => { - let value = tag - .get(1) - .and_then(|value| clean_value(value)) - .ok_or(ListingDecodeError::InvalidTag(TAG_GEOHASH))?; - if !is_public_geohash5(&value) { - return Err(ListingDecodeError::InvalidTag(TAG_GEOHASH)); - } - let value = value.to_ascii_lowercase(); - if geohash.as_ref().is_some_and(|existing| existing != &value) { - return Err(ListingDecodeError::InvalidTag(TAG_GEOHASH)); - } - geohash = Some(value); - } - TAG_DD => return Err(ListingDecodeError::InvalidTag(TAG_DD)), - TAG_DD_LAT => return Err(ListingDecodeError::InvalidTag(TAG_DD_LAT)), - TAG_DD_LON => return Err(ListingDecodeError::InvalidTag(TAG_DD_LON)), - TAG_LABEL => return Err(ListingDecodeError::InvalidTag(TAG_LABEL)), - TAG_LABEL_NS => return Err(ListingDecodeError::InvalidTag(TAG_LABEL_NS)), - TAG_INVENTORY => { - let value = tag - .get(1) - .ok_or(ListingDecodeError::InvalidTag(TAG_INVENTORY))?; - inventory_available = Some(parse_decimal(value, TAG_INVENTORY)?); - } - TAG_RADROOTS_AVAILABILITY_START => { - availability_start = Some(parse_u64_tag_value( - tag.get(1), - TAG_RADROOTS_AVAILABILITY_START, - )?); - } - TAG_EXPIRES_AT => { - availability_end = Some(parse_u64_tag_value(tag.get(1), TAG_EXPIRES_AT)?); - } - TAG_STATUS => { - let status = tag - .get(1) - .and_then(|value| clean_value(value)) - .unwrap_or_default(); - availability_status = Some(parse_status(&status)); - } - TAG_DELIVERY => { - let method = tag - .get(1) - .and_then(|value| clean_value(value)) - .unwrap_or_default(); - delivery_method = Some(match method.as_str() { - "pickup" => RadrootsListingDeliveryMethod::Pickup, - "local_delivery" => RadrootsListingDeliveryMethod::LocalDelivery, - "shipping" => RadrootsListingDeliveryMethod::Shipping, - "other" => RadrootsListingDeliveryMethod::Other { - method: tag - .get(2) - .and_then(|value| clean_value(value)) - .unwrap_or_default(), - }, - other => RadrootsListingDeliveryMethod::Other { - method: other.to_string(), - }, - }); - } - TAG_IMAGE => { - let url = tag - .get(1) - .ok_or(ListingDecodeError::InvalidTag(TAG_IMAGE))?; - if url.trim().is_empty() { - continue; - } - images.push(RadrootsListingImage { - url: url.to_string(), - size: tag.get(2).and_then(|value| parse_image_size(value)), - }); - } - _ => {} - } - } - - let availability = match availability_status { - Some(status) => Some(RadrootsListingAvailability::Status { status }), - None => match (availability_start, availability_end) { - (None, None) => None, - (start, end) => Some(RadrootsListingAvailability::Window { start, end }), - }, - }; - - let location = location - .map(|location| { - let geohash = geohash.ok_or(ListingDecodeError::InvalidTag(TAG_GEOHASH))?; - Ok::<RadrootsListingPublicLocation, ListingDecodeError>(RadrootsListingPublicLocation { - primary: location.primary, - city: location.city, - region: location.region, - country: location.country, - geohash, - }) - }) - .transpose()?; - - if farm_pubkey != farm_ref.pubkey { - return Err(ListingDecodeError::InvalidTag(TAG_P)); - } - - let primary_bin_id = - primary_bin_id.ok_or(ListingDecodeError::MissingTag(TAG_RADROOTS_PRIMARY_BIN))?; - let bins = build_bins(bin_drafts)?; - if !bins.iter().any(|bin| bin.bin_id == primary_bin_id) { - return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN)); - } - - let d_tag = RadrootsDTag::parse(&d_tag).map_err(|_| ListingDecodeError::InvalidTag(TAG_D))?; - let primary_bin_id = RadrootsInventoryBinId::parse(&primary_bin_id) - .map_err(|_| ListingDecodeError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN))?; - - Ok(RadrootsListing { - d_tag, - published_at, - farm: farm_ref, - product, - primary_bin_id, - bins, - resource_area, - plot, - discounts: if discounts.is_empty() { - None - } else { - Some(discounts) - }, - inventory_available, - availability, - delivery_method, - location, - images: if images.is_empty() { - None - } else { - Some(images) - }, - }) -} - -pub fn data_from_event( - id: String, - author: String, - published_at: u64, - kind: u32, - content: String, - tags: Vec<Vec<String>>, -) -> Result<RadrootsParsedData<RadrootsListing>, EventParseError> { - let listing = listing_from_event(kind, &tags, &content)?; - Ok(RadrootsParsedData::new( - id, - author, - published_at, - kind, - listing, - )) -} - -pub fn parsed_from_event( - id: String, - author: String, - published_at: u64, - kind: u32, - content: String, - tags: Vec<Vec<String>>, - sig: String, -) -> Result<RadrootsParsedEvent<RadrootsListing>, EventParseError> { - let data = data_from_event( - id.clone(), - author.clone(), - published_at, - kind, - content.clone(), - tags.clone(), - )?; - RadrootsParsedEvent::from_parts( - id, - author, - published_at, - kind, - content, - tags, - sig, - data.data, - ) -} - -pub fn data_from_nostr_event( - event: &RadrootsEventEnvelope, -) -> Result<RadrootsParsedData<RadrootsListing>, EventParseError> { - data_from_event( - event.id_str().to_string(), - event.author_str().to_string(), - event.created_at_u64(), - event.kind_u32(), - event.content().to_string(), - event.tags_as_vec(), - ) -} - -pub fn parsed_from_nostr_event( - event: &RadrootsEventEnvelope, -) -> Result<RadrootsParsedEvent<RadrootsListing>, EventParseError> { - parsed_from_event( - event.id_str().to_string(), - event.author_str().to_string(), - event.created_at_u64(), - event.kind_u32(), - event.content().to_string(), - event.tags_as_vec(), - event.sig_str().to_string(), - ) -} - -fn clean_value(value: &str) -> Option<String> { - let trimmed = value.trim(); - if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("null") { - None - } else { - Some(trimmed.to_string()) - } -} - -fn set_if_empty(target: &mut String, value: Option<&String>) { - if target.trim().is_empty() - && let Some(value) = value.and_then(|value| clean_value(value)) - { - *target = value; - } -} - -fn set_optional(target: &mut Option<String>, value: Option<&String>) { - if target.is_none() - && let Some(value) = value.and_then(|value| clean_value(value)) - { - *target = Some(value); - } -} - -fn parse_status(value: &str) -> RadrootsListingStatus { - match value.trim().to_ascii_lowercase().as_str() { - "active" => RadrootsListingStatus::Active, - "sold" => RadrootsListingStatus::Sold, - other => RadrootsListingStatus::Other { - value: other.to_string(), - }, - } -} - -fn parse_image_size(value: &str) -> Option<RadrootsListingImageSize> { - let (w_raw, h_raw) = value.split_once('x')?; - let w = w_raw.parse::<u32>().ok()?; - let h = h_raw.parse::<u32>().ok()?; - Some(RadrootsListingImageSize { w, h }) -} - -fn parse_discount(payload: &str) -> Result<RadrootsCoreDiscount, ListingDecodeError> { - #[cfg(feature = "serde_json")] - { - serde_json::from_str(payload) - .map_err(|_| ListingDecodeError::InvalidDiscount(TAG_RADROOTS_DISCOUNT)) - } - #[cfg(not(feature = "serde_json"))] - { - let _ = payload; - Err(ListingDecodeError::InvalidJson("discount")) - } -} - -#[derive(Clone, Debug)] -struct BinDraft { - bin_id: String, - order_index: usize, - quantity: Option<RadrootsCoreQuantity>, - display_amount: Option<RadrootsCoreDecimal>, - display_unit: Option<RadrootsCoreUnit>, - display_label: Option<String>, - price_per_canonical_unit: Option<RadrootsCoreQuantityPrice>, - display_price: Option<RadrootsCoreMoney>, - display_price_unit: Option<RadrootsCoreUnit>, -} - -fn upsert_bin<'a>( - bins: &'a mut Vec<BinDraft>, - bin_id: &str, - order_index: &mut usize, -) -> &'a mut BinDraft { - if let Some(position) = bins.iter().position(|bin| bin.bin_id == bin_id) { - return &mut bins[position]; - } - bins.push(BinDraft { - bin_id: bin_id.to_string(), - order_index: *order_index, - quantity: None, - display_amount: None, - display_unit: None, - display_label: None, - price_per_canonical_unit: None, - display_price: None, - display_price_unit: None, - }); - *order_index += 1; - let index = bins.len() - 1; - &mut bins[index] -} - -fn build_bins(mut drafts: Vec<BinDraft>) -> Result<Vec<RadrootsListingBin>, ListingDecodeError> { - drafts.sort_by_key(|draft| draft.order_index); - let mut bins = Vec::with_capacity(drafts.len()); - for draft in drafts { - let quantity = draft - .quantity - .ok_or(ListingDecodeError::MissingTag(TAG_RADROOTS_BIN))?; - let price = draft - .price_per_canonical_unit - .ok_or(ListingDecodeError::MissingTag(TAG_RADROOTS_PRICE))?; - if quantity.unit != price.quantity.unit { - return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_PRICE)); - } - bins.push(RadrootsListingBin { - bin_id: RadrootsInventoryBinId::parse(&draft.bin_id) - .map_err(|_| ListingDecodeError::InvalidTag(TAG_RADROOTS_BIN))?, - quantity, - price_per_canonical_unit: price, - display_amount: draft.display_amount, - display_unit: draft.display_unit, - display_label: draft.display_label, - display_price: draft.display_price, - display_price_unit: draft.display_price_unit, - }); - } - Ok(bins) -} diff --git a/crates/event_codec/src/listing/encode.rs b/crates/event_codec/src/listing/encode.rs @@ -1,163 +0,0 @@ -#[cfg(all(not(feature = "std"), test, feature = "serde_json"))] -use alloc::vec; -#[cfg(all(not(feature = "std"), feature = "serde_json"))] -use alloc::{format, string::ToString}; -#[cfg(not(feature = "std"))] -use alloc::{string::String, vec::Vec}; - -#[cfg(feature = "serde_json")] -use radroots_event::kinds::{KIND_LISTING, is_listing_kind}; -use radroots_event::listing::RadrootsListing; - -use crate::error::EventEncodeError; -use crate::listing::tags::listing_tags; -#[cfg(feature = "serde_json")] -use crate::listing::tags::listing_tags_full; -#[cfg(feature = "serde_json")] -use radroots_event::wire::RadrootsNip01EventWireParts; - -#[cfg(feature = "serde_json")] -const DEFAULT_KIND: u32 = KIND_LISTING; - -pub fn listing_build_tags(listing: &RadrootsListing) -> Result<Vec<Vec<String>>, EventEncodeError> { - listing_tags(listing) -} - -#[cfg(feature = "serde_json")] -pub fn to_wire_parts( - listing: &RadrootsListing, -) -> Result<RadrootsNip01EventWireParts, EventEncodeError> { - to_wire_parts_with_kind(listing, DEFAULT_KIND) -} - -#[cfg(feature = "serde_json")] -pub fn to_wire_parts_with_kind( - listing: &RadrootsListing, - kind: u32, -) -> Result<RadrootsNip01EventWireParts, EventEncodeError> { - if !is_listing_kind(kind) { - return Err(EventEncodeError::InvalidKind(kind)); - } - let tags = listing_tags_full(listing)?; - let content = listing_markdown_content(listing); - Ok(RadrootsNip01EventWireParts { - kind, - content, - tags, - }) -} - -#[cfg(feature = "serde_json")] -pub fn to_json_wire_parts_with_kind( - listing: &RadrootsListing, - kind: u32, -) -> Result<RadrootsNip01EventWireParts, EventEncodeError> { - if !is_listing_kind(kind) { - return Err(EventEncodeError::InvalidKind(kind)); - } - let tags = listing_tags_full(listing)?; - let content = serde_json::to_string(listing).map_err(|_| EventEncodeError::Json)?; - Ok(RadrootsNip01EventWireParts { - kind, - content, - tags, - }) -} - -#[cfg(feature = "serde_json")] -fn listing_markdown_content(listing: &RadrootsListing) -> String { - let title = listing.product.title.trim(); - let summary = listing - .product - .summary - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()); - - match (title.is_empty(), summary) { - (false, Some(summary)) => format!("# {title}\n\n{summary}"), - (false, None) => format!("# {title}"), - (true, Some(summary)) => summary.to_string(), - (true, None) => String::new(), - } -} - -#[cfg(all(test, feature = "serde_json"))] -mod tests { - use super::*; - use core::str::FromStr; - use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, - RadrootsCoreQuantityPrice, RadrootsCoreUnit, - }; - use radroots_event::{ - farm::RadrootsFarmRef, - ids::{RadrootsDTag, RadrootsInventoryBinId}, - listing::{RadrootsListingBin, RadrootsListingProduct}, - }; - - fn decimal(value: &str) -> RadrootsCoreDecimal { - RadrootsCoreDecimal::from_str(value).expect("decimal") - } - - fn listing_with(title: &str, summary: Option<&str>) -> RadrootsListing { - RadrootsListing { - d_tag: RadrootsDTag::parse("AAAAAAAAAAAAAAAAAAAAAA").expect("d tag"), - published_at: None, - farm: RadrootsFarmRef { - pubkey: "a".repeat(64), - d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".to_string(), - }, - product: RadrootsListingProduct { - key: "coffee".to_string(), - title: title.to_string(), - category: "produce".to_string(), - summary: summary.map(ToOwned::to_owned), - process: None, - lot: None, - location: None, - profile: None, - year: None, - }, - primary_bin_id: RadrootsInventoryBinId::parse("bin-1").expect("bin id"), - bins: vec![RadrootsListingBin { - bin_id: RadrootsInventoryBinId::parse("bin-1").expect("bin id"), - quantity: RadrootsCoreQuantity::new(decimal("1"), RadrootsCoreUnit::MassG), - price_per_canonical_unit: RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new(decimal("1"), RadrootsCoreCurrency::USD), - RadrootsCoreQuantity::new(RadrootsCoreDecimal::ONE, RadrootsCoreUnit::MassG), - ), - display_amount: None, - display_unit: None, - display_label: None, - display_price: None, - display_price_unit: None, - }], - resource_area: None, - plot: None, - discounts: None, - inventory_available: None, - availability: None, - delivery_method: None, - location: None, - images: None, - } - } - - #[test] - fn listing_markdown_content_covers_title_summary_combinations() { - assert_eq!( - listing_markdown_content(&listing_with("Coffee", Some("Washed"))), - "# Coffee\n\nWashed" - ); - assert_eq!( - listing_markdown_content(&listing_with("Coffee", None)), - "# Coffee" - ); - assert_eq!( - listing_markdown_content(&listing_with(" ", Some("Washed"))), - "Washed" - ); - assert_eq!(listing_markdown_content(&listing_with(" ", None)), ""); - } -} diff --git a/crates/event_codec/src/listing/tags.rs b/crates/event_codec/src/listing/tags.rs @@ -1,1322 +0,0 @@ -#![forbid(unsafe_code)] - -#[cfg(not(feature = "std"))] -use alloc::{ - format, - string::{String, ToString}, - vec, - vec::Vec, -}; - -#[cfg(any(feature = "serde_json", test))] -use radroots_core::RadrootsCoreDiscount; -use radroots_core::RadrootsCoreMoney; -use radroots_event::farm::RadrootsFarmRef; -use radroots_event::kinds::{KIND_FARM, KIND_PLOT, KIND_RESOURCE_AREA}; -use radroots_event::listing::{ - RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, - RadrootsListingDeliveryMethod, RadrootsListingImage, RadrootsListingPublicLocation, - RadrootsListingStatus, -}; -use radroots_event::location::{has_textual_locality, is_public_geohash5}; -use radroots_event::plot::RadrootsPlotRef; -use radroots_event::resource_area::RadrootsResourceAreaRef; -use radroots_event::tags::{TAG_D, TAG_PUBLISHED_AT}; - -use crate::d_tag::validate_d_tag; -use crate::error::EventEncodeError; - -const TAG_PRICE: &str = "price"; -const TAG_RADROOTS_BIN: &str = "radroots:bin"; -const TAG_RADROOTS_PRICE: &str = "radroots:price"; -#[cfg(feature = "serde_json")] -const TAG_RADROOTS_DISCOUNT: &str = "radroots:discount"; -const TAG_RADROOTS_PRIMARY_BIN: &str = "radroots:primary_bin"; -const TAG_RADROOTS_RESOURCE_AREA: &str = "radroots:resource_area"; -const TAG_RADROOTS_PLOT: &str = "radroots:plot"; -const TAG_LOCATION: &str = "location"; -const TAG_IMAGE: &str = "image"; -const TAG_GEOHASH: &str = "g"; -const TAG_INVENTORY: &str = "inventory"; -const TAG_DELIVERY: &str = "delivery"; -const TAG_RADROOTS_AVAILABILITY_START: &str = "radroots:availability_start"; -const TAG_STATUS: &str = "status"; -const TAG_EXPIRES_AT: &str = "expires_at"; -const TAG_P: &str = "p"; -const TAG_A: &str = "a"; - -#[derive(Clone, Copy, Debug)] -pub struct ListingTagOptions { - pub include_geohash: bool, - pub include_inventory: bool, - pub include_availability: bool, - pub include_delivery: bool, -} - -impl Default for ListingTagOptions { - fn default() -> Self { - Self { - include_geohash: true, - include_inventory: false, - include_availability: false, - include_delivery: false, - } - } -} - -impl ListingTagOptions { - pub fn with_trade_fields() -> Self { - Self { - include_inventory: true, - include_availability: true, - include_delivery: true, - ..Self::default() - } - } -} - -pub fn listing_tags(listing: &RadrootsListing) -> Result<Vec<Vec<String>>, EventEncodeError> { - listing_tags_with_options(listing, ListingTagOptions::default()) -} - -pub fn listing_tags_full(listing: &RadrootsListing) -> Result<Vec<Vec<String>>, EventEncodeError> { - listing_tags_with_options(listing, ListingTagOptions::with_trade_fields()) -} - -pub fn listing_tags_with_options( - listing: &RadrootsListing, - options: ListingTagOptions, -) -> Result<Vec<Vec<String>>, EventEncodeError> { - let d_tag = listing.d_tag.as_str(); - validate_d_tag(d_tag, "d")?; - if listing.bins.is_empty() { - return Err(EventEncodeError::EmptyRequiredField("bins")); - } - - let mut tags: Vec<Vec<String>> = Vec::new(); - tags.push(vec![TAG_D.to_string(), d_tag.to_string()]); - push_farm_tags(&mut tags, &listing.farm)?; - if let Some(resource_area) = listing.resource_area.as_ref() { - tags.push(tag_listing_resource_area(resource_area)?); - } - if let Some(plot) = listing.plot.as_ref() { - tags.push(tag_listing_plot(plot)?); - } - - let product = &listing.product; - push_tag_value(&mut tags, "key", &product.key); - push_tag_value(&mut tags, "title", &product.title); - push_tag_value(&mut tags, "category", &product.category); - if let Some(summary) = product.summary.as_deref() { - push_tag_value(&mut tags, "summary", summary); - } - if let Some(published_at) = listing.published_at { - tags.push(vec![TAG_PUBLISHED_AT.to_string(), published_at.to_string()]); - } - if let Some(process) = product.process.as_deref() { - push_tag_value(&mut tags, "process", process); - } - if let Some(lot) = product.lot.as_deref() { - push_tag_value(&mut tags, "lot", lot); - } - if let Some(location) = product.location.as_deref() { - push_tag_value(&mut tags, "location", location); - } - if let Some(profile) = product.profile.as_deref() { - push_tag_value(&mut tags, "profile", profile); - } - if let Some(year) = product.year.as_deref() { - push_tag_value(&mut tags, "year", year); - } - - tags.push(vec![ - TAG_RADROOTS_PRIMARY_BIN.to_string(), - listing.primary_bin_id.to_string(), - ]); - - let mut bins: Vec<&RadrootsListingBin> = listing.bins.iter().collect(); - if let Some(pos) = bins - .iter() - .position(|bin| bin.bin_id == listing.primary_bin_id) - { - let primary = bins.remove(pos); - bins.insert(0, primary); - } - - for bin in bins { - let price_tag = tag_listing_price(bin)?; - let bin_tag = tag_listing_bin(bin)?; - tags.push(bin_tag); - tags.push(price_tag); - if bin.bin_id == listing.primary_bin_id { - let total = bin_total_price(bin)?; - tags.push(tag_listing_price_generic(&total)); - } - } - - #[cfg(feature = "serde_json")] - if let Some(discounts) = &listing.discounts { - for discount in discounts { - let payload = discount_tag_payload(discount)?; - tags.push(vec![TAG_RADROOTS_DISCOUNT.to_string(), payload]); - } - } - #[cfg(not(feature = "serde_json"))] - if listing.discounts.as_ref().is_some() { - return Err(EventEncodeError::Json); - } - - if options.include_inventory - && let Some(inventory) = &listing.inventory_available - { - tags.push(vec![TAG_INVENTORY.to_string(), inventory.to_string()]); - } - - if options.include_availability - && let Some(availability) = &listing.availability - { - match availability { - RadrootsListingAvailability::Status { status } => { - tags.push(vec![ - TAG_STATUS.to_string(), - status_as_str(status).to_string(), - ]); - } - RadrootsListingAvailability::Window { start, end } => { - if let Some(start) = start { - tags.push(vec![ - TAG_RADROOTS_AVAILABILITY_START.to_string(), - start.to_string(), - ]); - } - if let Some(end) = end { - tags.push(vec![TAG_EXPIRES_AT.to_string(), end.to_string()]); - } - } - } - } - - if options.include_delivery - && let Some(method) = &listing.delivery_method - { - let mut tag = Vec::with_capacity(3); - tag.push(TAG_DELIVERY.to_string()); - match method { - RadrootsListingDeliveryMethod::Pickup => tag.push("pickup".into()), - RadrootsListingDeliveryMethod::LocalDelivery => tag.push("local_delivery".into()), - RadrootsListingDeliveryMethod::Shipping => tag.push("shipping".into()), - RadrootsListingDeliveryMethod::Other { method } => { - tag.push("other".into()); - tag.push(method.clone()); - } - } - tags.push(tag); - } - - if let Some(location) = &listing.location { - let primary = clean_value(&location.primary) - .ok_or(EventEncodeError::EmptyRequiredField("location.primary"))?; - if !has_textual_locality( - &location.primary, - location.city.as_deref(), - location.region.as_deref(), - location.country.as_deref(), - ) { - return Err(EventEncodeError::EmptyRequiredField("location.locality")); - } - let mut tag = Vec::with_capacity(5); - tag.push(TAG_LOCATION.to_string()); - tag.push(primary); - if let Some(city) = location.city.as_deref().and_then(clean_value) { - tag.push(city); - } - if let Some(region) = location.region.as_deref().and_then(clean_value) { - tag.push(region); - } - if let Some(country) = location.country.as_deref().and_then(clean_value) { - tag.push(country); - } - tags.push(tag); - if options.include_geohash { - push_location_geotag(&mut tags, location)?; - } - } - - if let Some(images) = &listing.images { - for image in images { - if let Some(tag) = tag_listing_image(image) { - tags.push(tag); - } - } - } - - Ok(tags) -} - -fn push_farm_tags( - tags: &mut Vec<Vec<String>>, - farm: &RadrootsFarmRef, -) -> Result<(), EventEncodeError> { - if farm.pubkey.trim().is_empty() { - return Err(EventEncodeError::EmptyRequiredField("farm.pubkey")); - } - if farm.d_tag.trim().is_empty() { - return Err(EventEncodeError::EmptyRequiredField("farm.d_tag")); - } - validate_d_tag(&farm.d_tag, "farm.d_tag")?; - let mut address = String::new(); - address.push_str(&KIND_FARM.to_string()); - address.push(':'); - address.push_str(&farm.pubkey); - address.push(':'); - address.push_str(&farm.d_tag); - tags.push(vec![TAG_P.to_string(), farm.pubkey.clone()]); - tags.push(vec![TAG_A.to_string(), address]); - Ok(()) -} - -fn tag_listing_resource_area( - area: &RadrootsResourceAreaRef, -) -> Result<Vec<String>, EventEncodeError> { - if area.pubkey.trim().is_empty() { - return Err(EventEncodeError::EmptyRequiredField("resource_area.pubkey")); - } - if area.d_tag.trim().is_empty() { - return Err(EventEncodeError::EmptyRequiredField("resource_area.d_tag")); - } - validate_d_tag(&area.d_tag, "resource_area.d_tag")?; - let mut address = String::new(); - address.push_str(&KIND_RESOURCE_AREA.to_string()); - address.push(':'); - address.push_str(&area.pubkey); - address.push(':'); - address.push_str(&area.d_tag); - Ok(vec![TAG_RADROOTS_RESOURCE_AREA.to_string(), address]) -} - -fn tag_listing_plot(plot: &RadrootsPlotRef) -> Result<Vec<String>, EventEncodeError> { - if plot.pubkey.trim().is_empty() { - return Err(EventEncodeError::EmptyRequiredField("plot.pubkey")); - } - if plot.d_tag.trim().is_empty() { - return Err(EventEncodeError::EmptyRequiredField("plot.d_tag")); - } - validate_d_tag(&plot.d_tag, "plot.d_tag")?; - let mut address = String::new(); - address.push_str(&KIND_PLOT.to_string()); - address.push(':'); - address.push_str(&plot.pubkey); - address.push(':'); - address.push_str(&plot.d_tag); - Ok(vec![TAG_RADROOTS_PLOT.to_string(), address]) -} - -fn tag_listing_bin(bin: &RadrootsListingBin) -> Result<Vec<String>, EventEncodeError> { - let unit = bin.quantity.unit; - if unit != unit.canonical_unit() { - return Err(EventEncodeError::EmptyRequiredField("bin.quantity")); - } - let mut tag = Vec::with_capacity(7); - tag.push(TAG_RADROOTS_BIN.to_string()); - tag.push(bin.bin_id.to_string()); - tag.push(bin.quantity.amount.to_string()); - tag.push(unit.code().to_string()); - if let Some(amount) = bin.display_amount.as_ref() { - let Some(unit) = bin.display_unit else { - return Err(EventEncodeError::EmptyRequiredField("bin.display_unit")); - }; - tag.push(amount.to_string()); - tag.push(unit.code().to_string()); - if let Some(label) = bin - .display_label - .as_deref() - .and_then(clean_value) - .or_else(|| bin.quantity.label.as_deref().and_then(clean_value)) - { - tag.push(label); - } - } else if bin.display_unit.is_some() { - return Err(EventEncodeError::EmptyRequiredField("bin.display_amount")); - } - Ok(tag) -} - -fn tag_listing_price(bin: &RadrootsListingBin) -> Result<Vec<String>, EventEncodeError> { - let price = &bin.price_per_canonical_unit; - if !price.is_price_per_canonical_unit() { - return Err(EventEncodeError::EmptyRequiredField( - "bin.price_per_canonical_unit", - )); - } - let mut tag = Vec::with_capacity(8); - tag.push(TAG_RADROOTS_PRICE.to_string()); - tag.push(bin.bin_id.to_string()); - tag.push(price.amount.amount.to_string()); - tag.push(price.amount.currency.as_str().to_string()); - tag.push(price.quantity.amount.to_string()); - tag.push(price.quantity.unit.code().to_string()); - match (&bin.display_price, bin.display_price_unit) { - (Some(price_display), Some(unit)) => { - if price_display.currency != price.amount.currency { - return Err(EventEncodeError::EmptyRequiredField("bin.display_price")); - } - tag.push(price_display.amount.to_string()); - tag.push(unit.code().to_string()); - } - (None, None) => {} - (None, Some(_)) => return Err(EventEncodeError::EmptyRequiredField("bin.display_price")), - (Some(_), None) => { - return Err(EventEncodeError::EmptyRequiredField( - "bin.display_price_unit", - )); - } - } - Ok(tag) -} - -fn bin_total_price(bin: &RadrootsListingBin) -> Result<RadrootsCoreMoney, EventEncodeError> { - bin.price_per_canonical_unit - .try_cost_for_quantity_in(&bin.quantity) - .map_err(|_| EventEncodeError::EmptyRequiredField("bin.price_per_canonical_unit")) -} - -fn tag_listing_price_generic(price: &RadrootsCoreMoney) -> Vec<String> { - let mut tag = Vec::with_capacity(4); - tag.push(TAG_PRICE.to_string()); - tag.push(price.amount.to_string()); - tag.push(price.currency.as_str().to_string()); - tag -} - -fn tag_listing_image(image: &RadrootsListingImage) -> Option<Vec<String>> { - let url = clean_value(&image.url)?; - let mut tag = Vec::with_capacity(3); - tag.push(TAG_IMAGE.to_string()); - tag.push(url); - if let Some(size) = &image.size { - tag.push(format!("{}x{}", size.w, size.h)); - } - Some(tag) -} - -fn push_location_geotag( - tags: &mut Vec<Vec<String>>, - location: &RadrootsListingPublicLocation, -) -> Result<(), EventEncodeError> { - let geohash = clean_value(&location.geohash) - .ok_or(EventEncodeError::EmptyRequiredField("location.geohash"))?; - if !is_public_geohash5(&geohash) { - return Err(EventEncodeError::InvalidField("location.geohash")); - } - tags.push(vec![TAG_GEOHASH.to_string(), geohash.to_ascii_lowercase()]); - Ok(()) -} - -fn push_tag_value(tags: &mut Vec<Vec<String>>, key: &str, value: &str) { - let _ = clean_value(value).map(|cleaned| tags.push(vec![key.to_string(), cleaned])); -} - -fn clean_value(value: &str) -> Option<String> { - let trimmed = value.trim(); - (!trimmed.is_empty() && !trimmed.eq_ignore_ascii_case("null")).then(|| trimmed.to_string()) -} - -#[cfg(any(feature = "serde_json", test))] -fn discount_tag_payload(discount: &RadrootsCoreDiscount) -> Result<String, EventEncodeError> { - #[cfg(feature = "serde_json")] - { - serde_json::to_string(discount).map_err(|_| EventEncodeError::Json) - } - #[cfg(not(feature = "serde_json"))] - { - let _ = discount; - Err(EventEncodeError::Json) - } -} - -fn status_as_str(status: &RadrootsListingStatus) -> &str { - match status { - RadrootsListingStatus::Active => "active", - RadrootsListingStatus::Sold => "sold", - RadrootsListingStatus::Other { value } => value.as_str(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use core::str::FromStr; - - use crate::test_fixtures::{ - CDN_PRIMARY_HTTPS, FIXTURE_ALICE_NPUB, FIXTURE_ALICE_PUBLIC_KEY_HEX, - }; - use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscountScope, - RadrootsCoreDiscountThreshold, RadrootsCoreDiscountValue, RadrootsCoreQuantity, - RadrootsCoreQuantityPrice, RadrootsCoreUnit, - }; - use radroots_event::ids::{RadrootsDTag, RadrootsInventoryBinId}; - use radroots_event::listing::{ - RadrootsListingImageSize, RadrootsListingProduct, RadrootsListingStatus, - }; - - const TEST_NPUB: &str = FIXTURE_ALICE_NPUB; - const TEST_PUBKEY_HEX: &str = FIXTURE_ALICE_PUBLIC_KEY_HEX; - const TEST_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAg"; - const TEST_FARM_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA"; - - fn cdn_url(path: &str) -> String { - format!("{CDN_PRIMARY_HTTPS}/{path}") - } - - fn decimal(value: &str) -> RadrootsCoreDecimal { - RadrootsCoreDecimal::from_str(value).expect("valid decimal") - } - - fn d_tag(raw: &str) -> RadrootsDTag { - raw.parse().unwrap() - } - - fn bin_id(raw: &str) -> RadrootsInventoryBinId { - raw.parse().unwrap() - } - - fn base_product() -> RadrootsListingProduct { - RadrootsListingProduct { - key: "coffee".to_string(), - title: "Coffee".to_string(), - category: "agri".to_string(), - summary: Some("summary".to_string()), - process: Some("washed".to_string()), - lot: Some("lot-1".to_string()), - location: Some("null".to_string()), - profile: Some("null".to_string()), - year: Some("2024".to_string()), - } - } - - fn base_bin() -> RadrootsListingBin { - RadrootsListingBin { - bin_id: bin_id("bin-1"), - quantity: RadrootsCoreQuantity::new(decimal("1000"), RadrootsCoreUnit::MassG) - .with_label("bag"), - price_per_canonical_unit: RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new(decimal("0.01"), RadrootsCoreCurrency::USD), - RadrootsCoreQuantity::new(RadrootsCoreDecimal::ONE, RadrootsCoreUnit::MassG), - ), - display_amount: Some(decimal("1")), - display_unit: Some(RadrootsCoreUnit::MassKg), - display_label: Some("kilobag".to_string()), - display_price: Some(RadrootsCoreMoney::new( - decimal("10"), - RadrootsCoreCurrency::USD, - )), - display_price_unit: Some(RadrootsCoreUnit::MassKg), - } - } - - fn base_listing() -> RadrootsListing { - RadrootsListing { - d_tag: d_tag(TEST_D_TAG), - published_at: None, - farm: RadrootsFarmRef { - pubkey: TEST_PUBKEY_HEX.to_string(), - d_tag: TEST_FARM_D_TAG.to_string(), - }, - product: base_product(), - primary_bin_id: bin_id("bin-1"), - bins: vec![base_bin()], - resource_area: Some(RadrootsResourceAreaRef { - pubkey: TEST_PUBKEY_HEX.to_string(), - d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_string(), - }), - plot: Some(RadrootsPlotRef { - pubkey: TEST_PUBKEY_HEX.to_string(), - d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".to_string(), - }), - discounts: None, - inventory_available: Some(decimal("2")), - availability: Some(RadrootsListingAvailability::Window { - start: Some(10), - end: Some(20), - }), - delivery_method: Some(RadrootsListingDeliveryMethod::Pickup), - location: Some(RadrootsListingPublicLocation { - primary: "Moyobamba".to_string(), - city: Some("Moyobamba".to_string()), - region: Some("San Martin".to_string()), - country: Some("PE".to_string()), - geohash: "9q8yy".to_string(), - }), - images: Some(vec![ - RadrootsListingImage { - url: cdn_url("a.jpg"), - size: Some(RadrootsListingImageSize { w: 1200, h: 800 }), - }, - RadrootsListingImage { - url: " ".to_string(), - size: None, - }, - ]), - } - } - - fn find_tag<'a>(tags: &'a [Vec<String>], key: &str) -> Option<&'a Vec<String>> { - tags.iter() - .find(|tag| tag.first().map(|v| v.as_str()) == Some(key)) - } - - #[test] - fn options_defaults_and_trade_fields() { - let defaults = ListingTagOptions::default(); - assert!(defaults.include_geohash); - assert!(!defaults.include_inventory); - assert!(!defaults.include_availability); - assert!(!defaults.include_delivery); - - let trade = ListingTagOptions::with_trade_fields(); - assert!(trade.include_geohash); - assert!(trade.include_inventory); - assert!(trade.include_availability); - assert!(trade.include_delivery); - } - - #[test] - fn clean_value_and_push_tag_value_cover_null_paths() { - assert_eq!(clean_value(" value "), Some("value".to_string())); - assert_eq!(clean_value(""), None); - assert_eq!(clean_value(" null "), None); - - let mut tags = Vec::new(); - push_tag_value(&mut tags, "k", "value"); - push_tag_value(&mut tags, "k", "null"); - push_tag_value(&mut tags, "k", ""); - assert_eq!(tags.len(), 1); - assert_eq!(tags[0], vec!["k".to_string(), "value".to_string()]); - } - - #[test] - fn location_geotag_accepts_public_geohash5() { - let mut tags = Vec::new(); - let location = RadrootsListingPublicLocation { - primary: "Test".to_string(), - city: Some("Town".to_string()), - region: None, - country: None, - geohash: "9Q8YY".to_string(), - }; - push_location_geotag(&mut tags, &location).expect("public geohash"); - - assert_eq!(tags, vec![vec!["g".to_string(), "9q8yy".to_string()]]); - assert!(find_tag(&tags, "l").is_none()); - assert!(find_tag(&tags, "L").is_none()); - } - - #[test] - fn location_geotag_rejects_blank_geohash() { - let mut tags = Vec::new(); - let location = RadrootsListingPublicLocation { - primary: "Test".to_string(), - city: Some("Town".to_string()), - region: None, - country: None, - geohash: " ".to_string(), - }; - - assert!(matches!( - push_location_geotag(&mut tags, &location), - Err(EventEncodeError::EmptyRequiredField("location.geohash")) - )); - assert!(tags.is_empty()); - } - - #[test] - fn location_geotag_rejects_non_public_geohash() { - let mut tags = Vec::new(); - let location = RadrootsListingPublicLocation { - primary: "Test".to_string(), - city: Some("Town".to_string()), - region: None, - country: None, - geohash: "9q8yyz".to_string(), - }; - - assert!(matches!( - push_location_geotag(&mut tags, &location), - Err(EventEncodeError::InvalidField("location.geohash")) - )); - assert!(tags.is_empty()); - } - - #[test] - fn image_and_status_helpers_cover_variants() { - let with_size = tag_listing_image(&RadrootsListingImage { - url: format!(" {CDN_PRIMARY_HTTPS}/a.jpg "), - size: Some(RadrootsListingImageSize { w: 10, h: 20 }), - }) - .expect("image tag"); - assert_eq!(with_size[0], "image"); - assert_eq!(with_size[2], "10x20"); - - let without_size = tag_listing_image(&RadrootsListingImage { - url: cdn_url("b.jpg"), - size: None, - }) - .expect("image tag"); - assert_eq!(without_size.len(), 2); - - assert!( - tag_listing_image(&RadrootsListingImage { - url: "null".to_string(), - size: None, - }) - .is_none() - ); - - assert_eq!(status_as_str(&RadrootsListingStatus::Active), "active"); - assert_eq!(status_as_str(&RadrootsListingStatus::Sold), "sold"); - assert_eq!( - status_as_str(&RadrootsListingStatus::Other { - value: "paused".to_string() - }), - "paused" - ); - } - - #[test] - fn discount_payload_without_serde_json_errors() { - let discount = RadrootsCoreDiscount { - scope: RadrootsCoreDiscountScope::Bin, - threshold: RadrootsCoreDiscountThreshold::BinCount { - bin_id: "bin-1".to_string(), - min: 2, - }, - value: RadrootsCoreDiscountValue::MoneyPerBin(RadrootsCoreMoney::new( - decimal("1"), - RadrootsCoreCurrency::USD, - )), - }; - #[cfg(feature = "serde_json")] - { - let payload = discount_tag_payload(&discount).expect("serde_json payload"); - assert!(payload.contains("\"scope\":\"bin\"")); - } - #[cfg(not(feature = "serde_json"))] - { - let _err = discount_tag_payload(&discount).expect_err("missing serde_json"); - } - } - - #[test] - fn farm_and_reference_tag_helpers_cover_errors_and_success() { - let mut tags = Vec::new(); - push_farm_tags( - &mut tags, - &RadrootsFarmRef { - pubkey: TEST_PUBKEY_HEX.to_string(), - d_tag: TEST_FARM_D_TAG.to_string(), - }, - ) - .expect("farm tags"); - assert!(find_tag(&tags, "p").is_some()); - assert!(find_tag(&tags, "a").is_some()); - - let err = push_farm_tags( - &mut Vec::new(), - &RadrootsFarmRef { - pubkey: "".to_string(), - d_tag: TEST_FARM_D_TAG.to_string(), - }, - ) - .expect_err("empty farm pubkey"); - assert!(matches!( - err, - EventEncodeError::EmptyRequiredField("farm.pubkey") - )); - - let err = push_farm_tags( - &mut Vec::new(), - &RadrootsFarmRef { - pubkey: TEST_PUBKEY_HEX.to_string(), - d_tag: "".to_string(), - }, - ) - .expect_err("empty farm d_tag"); - assert!(matches!( - err, - EventEncodeError::EmptyRequiredField("farm.d_tag") - )); - - let err = push_farm_tags( - &mut Vec::new(), - &RadrootsFarmRef { - pubkey: TEST_PUBKEY_HEX.to_string(), - d_tag: "farm:invalid".to_string(), - }, - ) - .expect_err("invalid farm d_tag"); - assert!(matches!(err, EventEncodeError::InvalidField("farm.d_tag"))); - - let area = tag_listing_resource_area(&RadrootsResourceAreaRef { - pubkey: TEST_PUBKEY_HEX.to_string(), - d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_string(), - }) - .expect("resource area"); - assert_eq!(area[0], "radroots:resource_area"); - - let plot = tag_listing_plot(&RadrootsPlotRef { - pubkey: TEST_PUBKEY_HEX.to_string(), - d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".to_string(), - }) - .expect("plot"); - assert_eq!(plot[0], "radroots:plot"); - } - - #[test] - fn bin_tag_and_price_helpers_cover_error_and_success_paths() { - let bin = base_bin(); - let bin_tag = tag_listing_bin(&bin).expect("bin tag"); - assert_eq!(bin_tag[0], "radroots:bin"); - let price_tag = tag_listing_price(&bin).expect("price tag"); - assert_eq!(price_tag[0], "radroots:price"); - let total = bin_total_price(&bin).expect("total price"); - let generic_tag = tag_listing_price_generic(&total); - assert_eq!(generic_tag[0], "price"); - - let mut non_canonical = base_bin(); - non_canonical.quantity = RadrootsCoreQuantity::new(decimal("1"), RadrootsCoreUnit::MassKg); - let err = tag_listing_bin(&non_canonical).expect_err("non canonical quantity"); - assert!(matches!( - err, - EventEncodeError::EmptyRequiredField("bin.quantity") - )); - - let mut missing_display_amount = base_bin(); - missing_display_amount.display_amount = None; - let err = tag_listing_bin(&missing_display_amount).expect_err("missing display amount"); - assert!(matches!( - err, - EventEncodeError::EmptyRequiredField("bin.display_amount") - )); - - let mut missing_display_unit = base_bin(); - missing_display_unit.display_unit = None; - let err = tag_listing_bin(&missing_display_unit).expect_err("missing display unit"); - assert!(matches!( - err, - EventEncodeError::EmptyRequiredField("bin.display_unit") - )); - - let mut no_display_bin = base_bin(); - no_display_bin.display_amount = None; - no_display_bin.display_unit = None; - no_display_bin.display_label = None; - let no_display_tag = tag_listing_bin(&no_display_bin).expect("bin tag without display"); - assert_eq!(no_display_tag.len(), 4); - - let mut invalid_unit_price = base_bin(); - invalid_unit_price.price_per_canonical_unit = RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD), - RadrootsCoreQuantity::new(decimal("2"), RadrootsCoreUnit::MassG), - ); - let err = tag_listing_price(&invalid_unit_price).expect_err("not unit price"); - assert!(matches!( - err, - EventEncodeError::EmptyRequiredField("bin.price_per_canonical_unit") - )); - - let mut mismatch_display_currency = base_bin(); - mismatch_display_currency.display_price = Some(RadrootsCoreMoney::new( - decimal("10"), - RadrootsCoreCurrency::EUR, - )); - let err = tag_listing_price(&mismatch_display_currency).expect_err("currency mismatch"); - assert!(matches!( - err, - EventEncodeError::EmptyRequiredField("bin.display_price") - )); - - let mut missing_display_price = base_bin(); - missing_display_price.display_price = None; - let err = tag_listing_price(&missing_display_price).expect_err("missing display price"); - assert!(matches!( - err, - EventEncodeError::EmptyRequiredField("bin.display_price") - )); - - let mut missing_display_price_unit = base_bin(); - missing_display_price_unit.display_price_unit = None; - let err = tag_listing_price(&missing_display_price_unit).expect_err("missing unit"); - assert!(matches!( - err, - EventEncodeError::EmptyRequiredField("bin.display_price_unit") - )); - - let mut no_display_price = base_bin(); - no_display_price.display_price = None; - no_display_price.display_price_unit = None; - let tag = tag_listing_price(&no_display_price).expect("price tag without display fields"); - let full_tag = tag_listing_price(&base_bin()).expect("price tag with display fields"); - assert_eq!(&tag[..6], &full_tag[..6]); - assert_eq!(tag.len(), 6); - - let mut invalid_cost = base_bin(); - invalid_cost.price_per_canonical_unit = RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD), - RadrootsCoreQuantity::new(RadrootsCoreDecimal::ONE, RadrootsCoreUnit::Each), - ); - let err = bin_total_price(&invalid_cost).expect_err("invalid cost conversion"); - assert!(matches!( - err, - EventEncodeError::EmptyRequiredField("bin.price_per_canonical_unit") - )); - } - - #[test] - fn listing_tags_propagate_bin_and_resource_area_validation_errors() { - let mut listing = base_listing(); - listing.discounts = None; - listing.bins[0].price_per_canonical_unit = RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD), - RadrootsCoreQuantity::new(decimal("2"), RadrootsCoreUnit::MassG), - ); - let err = listing_tags_with_options(&listing, ListingTagOptions::default()) - .expect_err("non unit price should fail listing tags"); - assert!(matches!( - err, - EventEncodeError::EmptyRequiredField("bin.price_per_canonical_unit") - )); - - let mut listing = base_listing(); - listing.discounts = None; - listing.bins[0].quantity = - RadrootsCoreQuantity::new(decimal("1"), RadrootsCoreUnit::MassKg); - listing.bins[0].price_per_canonical_unit = RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD), - RadrootsCoreQuantity::new(RadrootsCoreDecimal::ONE, RadrootsCoreUnit::MassG), - ); - let err = listing_tags_with_options(&listing, ListingTagOptions::default()) - .expect_err("non-canonical quantity should fail listing tags"); - assert!(matches!( - err, - EventEncodeError::EmptyRequiredField("bin.quantity") - )); - - let mut listing = base_listing(); - listing.discounts = None; - listing.bins[0].quantity = - RadrootsCoreQuantity::new(RadrootsCoreDecimal::ONE, RadrootsCoreUnit::Each); - listing.bins[0].price_per_canonical_unit = RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD), - RadrootsCoreQuantity::new(RadrootsCoreDecimal::ONE, RadrootsCoreUnit::MassG), - ); - let err = listing_tags_with_options(&listing, ListingTagOptions::default()) - .expect_err("invalid total conversion should fail listing tags"); - assert!(matches!( - err, - EventEncodeError::EmptyRequiredField("bin.price_per_canonical_unit") - )); - - let mut listing = base_listing(); - listing.discounts = None; - listing.resource_area = Some(RadrootsResourceAreaRef { - pubkey: TEST_PUBKEY_HEX.to_string(), - d_tag: "invalid".to_string(), - }); - let err = listing_tags_with_options(&listing, ListingTagOptions::default()) - .expect_err("invalid resource area d_tag should fail listing tags"); - assert!(matches!( - err, - EventEncodeError::InvalidField("resource_area.d_tag") - )); - } - - #[test] - fn listing_tags_required_errors_and_success_paths() { - let mut listing_with_discount = base_listing(); - listing_with_discount.discounts = Some(vec![RadrootsCoreDiscount { - scope: RadrootsCoreDiscountScope::Bin, - threshold: RadrootsCoreDiscountThreshold::BinCount { - bin_id: "bin-1".to_string(), - min: 2, - }, - value: RadrootsCoreDiscountValue::MoneyPerBin(RadrootsCoreMoney::new( - decimal("1"), - RadrootsCoreCurrency::USD, - )), - }]); - #[cfg(feature = "serde_json")] - { - let tags = - listing_tags_with_options(&listing_with_discount, ListingTagOptions::default()) - .expect("discount serialization works"); - assert!(find_tag(&tags, "radroots:discount").is_some()); - } - #[cfg(not(feature = "serde_json"))] - { - let _err = - listing_tags_with_options(&listing_with_discount, ListingTagOptions::default()) - .expect_err("discount serialization requires serde_json"); - } - - let mut listing = base_listing(); - listing.discounts = None; - let tags = listing_tags_with_options(&listing, ListingTagOptions::with_trade_fields()) - .expect("listing tags"); - assert!(find_tag(&tags, "d").is_some()); - assert!(find_tag(&tags, "p").is_some()); - assert!(find_tag(&tags, "a").is_some()); - assert!(find_tag(&tags, "radroots:primary_bin").is_some()); - assert!(find_tag(&tags, "radroots:bin").is_some()); - assert!(find_tag(&tags, "radroots:price").is_some()); - assert!(find_tag(&tags, "price").is_some()); - assert!(find_tag(&tags, "inventory").is_some()); - assert!(find_tag(&tags, "radroots:availability_start").is_some()); - assert!(find_tag(&tags, "expires_at").is_some()); - assert!(find_tag(&tags, "delivery").is_some()); - assert!(find_tag(&tags, "location").is_some()); - assert!(find_tag(&tags, "image").is_some()); - - let mut status_listing = base_listing(); - status_listing.discounts = None; - status_listing.availability = Some(RadrootsListingAvailability::Status { - status: RadrootsListingStatus::Other { - value: "paused".to_string(), - }, - }); - let status_tags = listing_tags_full(&status_listing).expect("status tags"); - assert!(status_tags.iter().any(|tag| { - tag.first().map(|v| v.as_str()) == Some("status") - && tag.get(1).map(|v| v.as_str()) == Some("paused") - })); - - for method in [ - RadrootsListingDeliveryMethod::Pickup, - RadrootsListingDeliveryMethod::LocalDelivery, - RadrootsListingDeliveryMethod::Shipping, - RadrootsListingDeliveryMethod::Other { - method: "scheduled".to_string(), - }, - ] { - let mut delivery_listing = base_listing(); - delivery_listing.discounts = None; - delivery_listing.delivery_method = Some(method); - let method_tags = listing_tags_full(&delivery_listing).expect("delivery tags"); - assert!(find_tag(&method_tags, "delivery").is_some()); - } - - let mut no_availability = base_listing(); - no_availability.discounts = None; - no_availability.availability = None; - let no_availability_tags = listing_tags_with_options( - &no_availability, - ListingTagOptions { - include_availability: true, - ..ListingTagOptions::default() - }, - ) - .expect("availability option without value"); - assert!(find_tag(&no_availability_tags, "status").is_none()); - assert!(find_tag(&no_availability_tags, "radroots:availability_start").is_none()); - assert!(find_tag(&no_availability_tags, "expires_at").is_none()); - - let mut empty_window_availability = base_listing(); - empty_window_availability.discounts = None; - empty_window_availability.availability = Some(RadrootsListingAvailability::Window { - start: None, - end: None, - }); - let empty_window_tags = listing_tags_with_options( - &empty_window_availability, - ListingTagOptions { - include_availability: true, - ..ListingTagOptions::default() - }, - ) - .expect("availability window without bounds"); - assert!(find_tag(&empty_window_tags, "radroots:availability_start").is_none()); - assert!(find_tag(&empty_window_tags, "expires_at").is_none()); - - let mut no_delivery = base_listing(); - no_delivery.discounts = None; - no_delivery.delivery_method = None; - let no_delivery_tags = listing_tags_with_options( - &no_delivery, - ListingTagOptions { - include_delivery: true, - ..ListingTagOptions::default() - }, - ) - .expect("delivery option without value"); - assert!(find_tag(&no_delivery_tags, "delivery").is_none()); - - let mut no_inventory = base_listing(); - no_inventory.discounts = None; - no_inventory.inventory_available = None; - let no_inventory_tags = listing_tags_with_options( - &no_inventory, - ListingTagOptions { - include_inventory: true, - ..ListingTagOptions::default() - }, - ) - .expect("inventory option without value"); - assert!(find_tag(&no_inventory_tags, "inventory").is_none()); - - let mut no_geo = base_listing(); - no_geo.discounts = None; - let no_geo_tags = listing_tags_with_options( - &no_geo, - ListingTagOptions { - include_geohash: false, - ..ListingTagOptions::default() - }, - ) - .expect("location without geotags"); - assert!(find_tag(&no_geo_tags, "location").is_some()); - assert!(find_tag(&no_geo_tags, "g").is_none()); - assert!(find_tag(&no_geo_tags, "l").is_none()); - - let geohash_only_tags = listing_tags_with_options( - &no_geo, - ListingTagOptions { - include_geohash: true, - ..ListingTagOptions::default() - }, - ) - .expect("location with geohash only"); - assert!(find_tag(&geohash_only_tags, "g").is_some()); - assert!(find_tag(&geohash_only_tags, "l").is_none()); - } - - #[test] - fn listing_tags_required_field_errors() { - let mut listing = base_listing(); - - listing.d_tag = d_tag("listing:invalid"); - let err = listing_tags(&listing).expect_err("invalid d"); - assert!(matches!(err, EventEncodeError::InvalidField("d"))); - - listing = base_listing(); - listing.bins.clear(); - let err = listing_tags(&listing).expect_err("missing bins"); - assert!(matches!(err, EventEncodeError::EmptyRequiredField("bins"))); - - listing = base_listing(); - listing.farm.pubkey = "".to_string(); - let err = listing_tags(&listing).expect_err("missing farm pubkey"); - assert!(matches!( - err, - EventEncodeError::EmptyRequiredField("farm.pubkey") - )); - - listing = base_listing(); - listing.resource_area = Some(RadrootsResourceAreaRef { - pubkey: "".to_string(), - d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_string(), - }); - let err = listing_tags(&listing).expect_err("missing resource_area pubkey"); - assert!(matches!( - err, - EventEncodeError::EmptyRequiredField("resource_area.pubkey") - )); - - listing = base_listing(); - listing.resource_area = Some(RadrootsResourceAreaRef { - pubkey: TEST_PUBKEY_HEX.to_string(), - d_tag: "".to_string(), - }); - let err = listing_tags(&listing).expect_err("missing resource_area d_tag"); - assert!(matches!( - err, - EventEncodeError::EmptyRequiredField("resource_area.d_tag") - )); - - listing = base_listing(); - listing.plot = Some(RadrootsPlotRef { - pubkey: "".to_string(), - d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".to_string(), - }); - let err = listing_tags(&listing).expect_err("missing plot pubkey"); - assert!(matches!( - err, - EventEncodeError::EmptyRequiredField("plot.pubkey") - )); - - listing = base_listing(); - listing.plot = Some(RadrootsPlotRef { - pubkey: TEST_PUBKEY_HEX.to_string(), - d_tag: "".to_string(), - }); - let err = listing_tags(&listing).expect_err("missing plot d_tag"); - assert!(matches!( - err, - EventEncodeError::EmptyRequiredField("plot.d_tag") - )); - - listing = base_listing(); - listing.plot = Some(RadrootsPlotRef { - pubkey: TEST_PUBKEY_HEX.to_string(), - d_tag: "plot:invalid".to_string(), - }); - let err = listing_tags(&listing).expect_err("invalid plot d_tag"); - assert!(matches!(err, EventEncodeError::InvalidField("plot.d_tag"))); - } - - #[test] - fn listing_tags_omit_optional_refs_and_product_fields_when_absent() { - let mut listing = base_listing(); - listing.discounts = None; - listing.resource_area = None; - listing.plot = None; - listing.product.summary = None; - listing.product.process = None; - listing.product.lot = None; - listing.product.location = None; - listing.product.profile = None; - listing.product.year = None; - listing.location = None; - listing.images = None; - - let tags = listing_tags(&listing).expect("listing tags without optional fields"); - assert!(find_tag(&tags, "d").is_some()); - assert!(find_tag(&tags, "p").is_some()); - assert!(find_tag(&tags, "a").is_some()); - assert!(find_tag(&tags, "radroots:resource_area").is_none()); - assert!(find_tag(&tags, "radroots:plot").is_none()); - assert!(find_tag(&tags, "summary").is_none()); - assert!(find_tag(&tags, "process").is_none()); - assert!(find_tag(&tags, "lot").is_none()); - assert!(find_tag(&tags, "location").is_none()); - assert!(find_tag(&tags, "profile").is_none()); - assert!(find_tag(&tags, "year").is_none()); - assert!(find_tag(&tags, "image").is_none()); - } - - #[test] - fn listing_tags_location_and_product_cleaning_paths() { - let mut listing = base_listing(); - listing.discounts = None; - listing.product.location = Some(" null ".to_string()); - listing.product.profile = Some(" ".to_string()); - listing.location = None; - listing.images = Some(vec![RadrootsListingImage { - url: "null".to_string(), - size: None, - }]); - let tags = listing_tags_full(&listing).expect("cleaning path tags"); - assert!(find_tag(&tags, "location").is_none()); - assert!(find_tag(&tags, "profile").is_none()); - assert!(find_tag(&tags, "image").is_none()); - } - - #[test] - fn listing_tags_preserve_location_fields_when_present() { - let mut listing = base_listing(); - listing.discounts = None; - listing.images = None; - listing.location = Some(RadrootsListingPublicLocation { - primary: "Moyobamba".to_string(), - city: Some("Moyobamba".to_string()), - region: Some("San Martin".to_string()), - country: Some("PE".to_string()), - geohash: "6gkzw".to_string(), - }); - let tags = listing_tags_full(&listing).expect("location tags"); - let location = find_tag(&tags, "location").expect("location tag"); - assert_eq!(location.get(1).map(|v| v.as_str()), Some("Moyobamba")); - assert_eq!(location.get(2).map(|v| v.as_str()), Some("Moyobamba")); - assert_eq!(location.get(3).map(|v| v.as_str()), Some("San Martin")); - assert_eq!(location.get(4).map(|v| v.as_str()), Some("PE")); - assert!(find_tag(&tags, "image").is_none()); - } - - #[test] - fn listing_tags_reorders_primary_bin_and_falls_back_to_quantity_label() { - let mut listing = base_listing(); - listing.discounts = None; - - let mut primary = base_bin(); - primary.bin_id = bin_id("bin-1"); - primary.display_label = None; - primary.quantity = primary.quantity.clone().with_label("fallback-label"); - - let mut secondary = base_bin(); - secondary.bin_id = bin_id("bin-2"); - listing.primary_bin_id = bin_id("bin-1"); - listing.bins = vec![secondary, primary]; - - let tags = listing_tags(&listing).expect("listing tags"); - let first_bin = tags - .iter() - .find(|tag| tag.first().map(|v| v.as_str()) == Some("radroots:bin")) - .expect("first bin tag"); - assert_eq!(first_bin.get(1).map(|v| v.as_str()), Some("bin-1")); - assert_eq!(first_bin.get(6).map(|v| v.as_str()), Some("fallback-label")); - - let mut listing_without_primary_match = base_listing(); - listing_without_primary_match.discounts = None; - let mut first = base_bin(); - first.bin_id = bin_id("bin-2"); - first.display_label = None; - first.quantity = RadrootsCoreQuantity::new(decimal("1000"), RadrootsCoreUnit::MassG); - let mut second = base_bin(); - second.bin_id = bin_id("bin-1"); - listing_without_primary_match.primary_bin_id = bin_id("bin-missing"); - listing_without_primary_match.bins = vec![first, second]; - - let tags = listing_tags(&listing_without_primary_match).expect("listing tags"); - let first_bin = tags - .iter() - .find(|tag| tag.first().map(|v| v.as_str()) == Some("radroots:bin")) - .expect("first bin tag"); - assert_eq!(first_bin.get(1).map(|v| v.as_str()), Some("bin-2")); - assert_eq!(first_bin.len(), 6); - } - - #[test] - fn listing_tags_location_handles_partial_optional_components() { - let mut listing = base_listing(); - listing.discounts = None; - listing.location = Some(RadrootsListingPublicLocation { - primary: "Moyobamba".to_string(), - city: Some(" ".to_string()), - region: Some("San Martin".to_string()), - country: Some(" ".to_string()), - geohash: "9q8yy".to_string(), - }); - - let tags = listing_tags(&listing).expect("listing tags"); - let location = find_tag(&tags, "location").expect("location tag"); - assert_eq!(location.first().map(|v| v.as_str()), Some("location")); - assert_eq!(location.get(1).map(|v| v.as_str()), Some("Moyobamba")); - assert_eq!(location.get(2).map(|v| v.as_str()), Some("San Martin")); - assert_eq!(location.len(), 3); - - listing.location = Some(RadrootsListingPublicLocation { - primary: "Moyobamba".to_string(), - city: Some("Moyobamba".to_string()), - region: Some(" ".to_string()), - country: Some("PE".to_string()), - geohash: "9q8yy".to_string(), - }); - let tags = listing_tags(&listing).expect("listing tags"); - let location = find_tag(&tags, "location").expect("location tag"); - assert_eq!(location.get(1).map(|v| v.as_str()), Some("Moyobamba")); - assert_eq!(location.get(2).map(|v| v.as_str()), Some("Moyobamba")); - assert_eq!(location.get(3).map(|v| v.as_str()), Some("PE")); - assert_eq!(location.len(), 4); - } - - #[test] - fn listing_tags_supports_npub_farm_pubkey() { - let mut listing = base_listing(); - listing.discounts = None; - listing.farm.pubkey = TEST_NPUB.to_string(); - let tags = listing_tags(&listing).expect("npub farm tags"); - assert!(tags.iter().any(|tag| { - tag.first().map(|v| v.as_str()) == Some("p") - && tag.get(1).map(|v| v.as_str()) == Some(TEST_NPUB) - })); - } -} diff --git a/crates/event_codec/src/manifest.rs b/crates/event_codec/src/manifest.rs @@ -300,6 +300,7 @@ fn standard_label(value: RadrootsNostrStandard) -> &'static str { RadrootsNostrStandard::Nip90 => "nip90", RadrootsNostrStandard::Nip94 => "nip94", RadrootsNostrStandard::Nip98 => "nip98", + RadrootsNostrStandard::Nip99 => "nip99", RadrootsNostrStandard::Radroots => "radroots", } } @@ -376,8 +377,8 @@ fn tag_semantic_label(value: RadrootsTagSemantic) -> &'static str { RadrootsTagSemantic::Identifier => "identifier", RadrootsTagSemantic::Image => "image", RadrootsTagSemantic::Kind => "kind", - RadrootsTagSemantic::ListingAddress => "listing_address", - RadrootsTagSemantic::ListingSnapshot => "listing_snapshot", + RadrootsTagSemantic::ClassifiedListingAddress => "listing_address", + RadrootsTagSemantic::OperationalListingSnapshot => "listing_snapshot", RadrootsTagSemantic::ListDescription => "list_description", RadrootsTagSemantic::Location => "location", RadrootsTagSemantic::Participant => "participant", @@ -434,8 +435,10 @@ fn reducer_label(value: RadrootsReducer) -> &'static str { RadrootsReducer::FarmOpsProjection => "farm_ops_projection", RadrootsReducer::GroupProjection => "group_projection", RadrootsReducer::KnowledgeProjection => "knowledge_projection", - RadrootsReducer::ListingInventoryAccounting => "listing_inventory_accounting", - RadrootsReducer::ListingProjection => "listing_projection", + RadrootsReducer::OperationalListingInventoryAccounting => { + "operational_listing_inventory_accounting" + } + RadrootsReducer::OperationalListingProjection => "operational_listing_projection", RadrootsReducer::MarketProjection => "market_projection", RadrootsReducer::OrderProjection => "order_projection", RadrootsReducer::ProfileProjection => "profile_projection", @@ -445,3 +448,26 @@ fn reducer_label(value: RadrootsReducer) -> &'static str { RadrootsReducer::TradeValidation => "trade_validation", } } + +#[cfg(test)] +mod tests { + use super::{reducer_label, standard_label}; + use radroots_event::contract::{RadrootsNostrStandard, RadrootsReducer}; + + #[test] + fn classified_listing_standard_label_is_nip99() { + assert_eq!(standard_label(RadrootsNostrStandard::Nip99), "nip99"); + } + + #[test] + fn operational_listing_reducer_labels_are_unambiguous() { + assert_eq!( + reducer_label(RadrootsReducer::OperationalListingProjection), + "operational_listing_projection" + ); + assert_eq!( + reducer_label(RadrootsReducer::OperationalListingInventoryAccounting), + "operational_listing_inventory_accounting" + ); + } +} diff --git a/crates/event_codec/src/operational_listing/decode.rs b/crates/event_codec/src/operational_listing/decode.rs @@ -0,0 +1,894 @@ +#[cfg(not(feature = "std"))] +use alloc::{ + string::{String, ToString}, + vec::Vec, +}; + +use radroots_core::{ + RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreMoney, + RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit, +}; +use radroots_event::{ + RadrootsEventEnvelope, + farm::RadrootsFarmRef, + ids::{RadrootsDTag, RadrootsInventoryBinId}, + kinds::{KIND_FARM, KIND_PLOT, KIND_RESOURCE_AREA, is_classified_listing_kind}, + location::is_public_geohash5, + operational_listing::RadrootsOperationalListingParseError, + operational_listing::{ + RadrootsOperationalListing, RadrootsOperationalListingAvailability, + RadrootsOperationalListingBin, RadrootsOperationalListingDeliveryMethod, + RadrootsOperationalListingImage, RadrootsOperationalListingImageSize, + RadrootsOperationalListingProduct, RadrootsOperationalListingPublicLocation, + RadrootsOperationalListingStatus, + }, + plot::RadrootsPlotRef, + resource_area::RadrootsResourceAreaRef, + tags::{TAG_D, TAG_PUBLISHED_AT}, +}; + +use crate::d_tag::validate_d_tag_tag; +use crate::error::EventParseError; +use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent}; + +const EXPECTED_CLASSIFIED_LISTING_KINDS: &str = "30402"; +const TAG_A: &str = "a"; +const TAG_P: &str = "p"; +const TAG_PRICE: &str = "price"; +const TAG_RADROOTS_BIN: &str = "radroots:bin"; +const TAG_RADROOTS_PRICE: &str = "radroots:price"; +const TAG_RADROOTS_DISCOUNT: &str = "radroots:discount"; +const TAG_RADROOTS_PRIMARY_BIN: &str = "radroots:primary_bin"; +const TAG_RADROOTS_RESOURCE_AREA: &str = "radroots:resource_area"; +const TAG_RADROOTS_PLOT: &str = "radroots:plot"; +const TAG_LOCATION: &str = "location"; +const TAG_IMAGE: &str = "image"; +const TAG_GEOHASH: &str = "g"; +const TAG_LABEL: &str = "l"; +const TAG_LABEL_NS: &str = "L"; +const TAG_DD: &str = "dd"; +const TAG_DD_LAT: &str = "dd.lat"; +const TAG_DD_LON: &str = "dd.lon"; +const TAG_INVENTORY: &str = "inventory"; +const TAG_DELIVERY: &str = "delivery"; +const TAG_RADROOTS_AVAILABILITY_START: &str = "radroots:availability_start"; +const TAG_STATUS: &str = "status"; +const TAG_EXPIRES_AT: &str = "expires_at"; + +#[derive(Clone, Debug, PartialEq, Eq)] +enum OperationalListingDecodeError { + MissingTag(&'static str), + InvalidTag(&'static str), + InvalidNumber(&'static str), + InvalidUnit(&'static str), + InvalidCurrency(&'static str), + InvalidJson(&'static str), + #[cfg(feature = "serde_json")] + InvalidDiscount(&'static str), +} + +impl OperationalListingDecodeError { + fn into_listing_parse_error(self) -> RadrootsOperationalListingParseError { + match self { + Self::MissingTag(field) => { + RadrootsOperationalListingParseError::MissingTag(field.to_string()) + } + Self::InvalidTag(field) => { + RadrootsOperationalListingParseError::InvalidTag(field.to_string()) + } + Self::InvalidNumber(field) => { + RadrootsOperationalListingParseError::InvalidNumber(field.to_string()) + } + Self::InvalidUnit(_) => RadrootsOperationalListingParseError::InvalidUnit, + Self::InvalidCurrency(_) => RadrootsOperationalListingParseError::InvalidCurrency, + Self::InvalidJson(field) => { + RadrootsOperationalListingParseError::InvalidJson(field.to_string()) + } + #[cfg(feature = "serde_json")] + Self::InvalidDiscount(field) => { + RadrootsOperationalListingParseError::InvalidDiscount(field.to_string()) + } + } + } + + fn into_event_parse_error(self) -> EventParseError { + match self { + Self::MissingTag(field) => EventParseError::MissingTag(field), + Self::InvalidTag(field) + | Self::InvalidNumber(field) + | Self::InvalidUnit(field) + | Self::InvalidCurrency(field) => EventParseError::InvalidTag(field), + #[cfg(feature = "serde_json")] + Self::InvalidDiscount(field) => EventParseError::InvalidTag(field), + Self::InvalidJson(_) => EventParseError::InvalidJson("content"), + } + } +} + +struct OperationalListingLocationDraft { + primary: String, + city: Option<String>, + region: Option<String>, + country: Option<String>, +} + +fn parse_decimal( + value: &str, + field: &'static str, +) -> Result<RadrootsCoreDecimal, OperationalListingDecodeError> { + value + .parse::<RadrootsCoreDecimal>() + .map_err(|_| OperationalListingDecodeError::InvalidNumber(field)) +} + +fn reject_private_listing_location_content( + content: &str, +) -> Result<(), OperationalListingDecodeError> { + let trimmed = content.trim(); + if !trimmed.starts_with('{') { + return Ok(()); + } + #[cfg(feature = "serde_json")] + { + let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else { + return Ok(()); + }; + let Some(location) = value.get("location").and_then(|value| value.as_object()) else { + return Ok(()); + }; + for key in [ + "lat", + "lng", + "lon", + "point", + "polygon", + "coordinates", + "accuracy", + "altitude", + "label", + "tag_0", + "gcs", + ] { + if location.contains_key(key) { + return Err(OperationalListingDecodeError::InvalidJson("location")); + } + } + } + Ok(()) +} + +fn parse_currency( + value: &str, + field: &'static str, +) -> Result<RadrootsCoreCurrency, OperationalListingDecodeError> { + let upper = value.trim().to_ascii_uppercase(); + RadrootsCoreCurrency::from_str_upper(&upper) + .map_err(|_| OperationalListingDecodeError::InvalidCurrency(field)) +} + +fn parse_unit( + value: &str, + field: &'static str, +) -> Result<RadrootsCoreUnit, OperationalListingDecodeError> { + value + .parse::<RadrootsCoreUnit>() + .map_err(|_| OperationalListingDecodeError::InvalidUnit(field)) +} + +fn parse_u64_tag_value( + value: Option<&String>, + field: &'static str, +) -> Result<u64, OperationalListingDecodeError> { + value + .ok_or(OperationalListingDecodeError::InvalidTag(field))? + .parse::<u64>() + .map_err(|_| OperationalListingDecodeError::InvalidNumber(field)) +} + +fn parse_d_tag(tags: &[Vec<String>]) -> Result<String, OperationalListingDecodeError> { + let tag = tags + .iter() + .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_D)) + .ok_or(OperationalListingDecodeError::MissingTag(TAG_D))?; + let value = tag + .get(1) + .map(|value| value.to_string()) + .ok_or(OperationalListingDecodeError::InvalidTag(TAG_D))?; + if value.trim().is_empty() { + return Err(OperationalListingDecodeError::InvalidTag(TAG_D)); + } + validate_d_tag_tag(&value, TAG_D) + .map_err(|_| OperationalListingDecodeError::InvalidTag(TAG_D))?; + Ok(value) +} + +fn parse_farm_ref(tags: &[Vec<String>]) -> Result<RadrootsFarmRef, OperationalListingDecodeError> { + for tag in tags + .iter() + .filter(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_A)) + { + let value = tag + .get(1) + .map(|value| value.to_string()) + .ok_or(OperationalListingDecodeError::InvalidTag(TAG_A))?; + let mut parts = value.splitn(3, ':'); + let kind = parts + .next() + .and_then(|raw| raw.parse::<u32>().ok()) + .ok_or(OperationalListingDecodeError::InvalidTag(TAG_A))?; + if kind != KIND_FARM { + continue; + } + let pubkey = parts + .next() + .ok_or(OperationalListingDecodeError::InvalidTag(TAG_A))? + .to_string(); + let d_tag = parts + .next() + .ok_or(OperationalListingDecodeError::InvalidTag(TAG_A))? + .to_string(); + if pubkey.trim().is_empty() || d_tag.trim().is_empty() { + return Err(OperationalListingDecodeError::InvalidTag(TAG_A)); + } + validate_d_tag_tag(&d_tag, TAG_A) + .map_err(|_| OperationalListingDecodeError::InvalidTag(TAG_A))?; + return Ok(RadrootsFarmRef { pubkey, d_tag }); + } + Err(OperationalListingDecodeError::MissingTag(TAG_A)) +} + +fn parse_farm_pubkey(tags: &[Vec<String>]) -> Result<String, OperationalListingDecodeError> { + let tag = tags + .iter() + .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_P)) + .ok_or(OperationalListingDecodeError::MissingTag(TAG_P))?; + let value = tag + .get(1) + .map(|value| value.to_string()) + .ok_or(OperationalListingDecodeError::InvalidTag(TAG_P))?; + if value.trim().is_empty() { + return Err(OperationalListingDecodeError::InvalidTag(TAG_P)); + } + Ok(value) +} + +fn parse_resource_area( + tags: &[Vec<String>], +) -> Result<Option<RadrootsResourceAreaRef>, OperationalListingDecodeError> { + let tag = tags + .iter() + .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_RADROOTS_RESOURCE_AREA)); + let Some(tag) = tag else { + return Ok(None); + }; + let value = tag.get(1).map(|value| value.to_string()).ok_or( + OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA), + )?; + let mut parts = value.splitn(3, ':'); + let kind = parts.next().and_then(|raw| raw.parse::<u32>().ok()).ok_or( + OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA), + )?; + if kind != KIND_RESOURCE_AREA { + return Err(OperationalListingDecodeError::InvalidTag( + TAG_RADROOTS_RESOURCE_AREA, + )); + } + let pubkey = parts + .next() + .ok_or(OperationalListingDecodeError::InvalidTag( + TAG_RADROOTS_RESOURCE_AREA, + ))? + .to_string(); + let d_tag = parts + .next() + .ok_or(OperationalListingDecodeError::InvalidTag( + TAG_RADROOTS_RESOURCE_AREA, + ))? + .to_string(); + if pubkey.trim().is_empty() || d_tag.trim().is_empty() { + return Err(OperationalListingDecodeError::InvalidTag( + TAG_RADROOTS_RESOURCE_AREA, + )); + } + validate_d_tag_tag(&d_tag, TAG_RADROOTS_RESOURCE_AREA) + .map_err(|_| OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA))?; + Ok(Some(RadrootsResourceAreaRef { pubkey, d_tag })) +} + +fn parse_plot_ref( + tags: &[Vec<String>], +) -> Result<Option<RadrootsPlotRef>, OperationalListingDecodeError> { + let tag = tags + .iter() + .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_RADROOTS_PLOT)); + let Some(tag) = tag else { + return Ok(None); + }; + let value = tag + .get(1) + .map(|value| value.to_string()) + .ok_or(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT))?; + let mut parts = value.splitn(3, ':'); + let kind = parts + .next() + .and_then(|raw| raw.parse::<u32>().ok()) + .ok_or(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT))?; + if kind != KIND_PLOT { + return Err(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT)); + } + let pubkey = parts + .next() + .ok_or(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT))? + .to_string(); + let d_tag = parts + .next() + .ok_or(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT))? + .to_string(); + if pubkey.trim().is_empty() || d_tag.trim().is_empty() { + return Err(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT)); + } + validate_d_tag_tag(&d_tag, TAG_RADROOTS_PLOT) + .map_err(|_| OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT))?; + Ok(Some(RadrootsPlotRef { pubkey, d_tag })) +} + +pub fn operational_listing_from_event( + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsOperationalListing, EventParseError> { + if !is_classified_listing_kind(kind) { + return Err(EventParseError::InvalidKind { + expected: EXPECTED_CLASSIFIED_LISTING_KINDS, + got: kind, + }); + } + decode_operational_listing_from_event_parts(tags, content) + .map_err(OperationalListingDecodeError::into_event_parse_error) +} + +pub fn operational_listing_from_event_parts( + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsOperationalListing, RadrootsOperationalListingParseError> { + decode_operational_listing_from_event_parts(tags, content) + .map_err(OperationalListingDecodeError::into_listing_parse_error) +} + +pub fn operational_listing_from_nostr_event( + event: &RadrootsEventEnvelope, +) -> Result<RadrootsOperationalListing, RadrootsOperationalListingParseError> { + if !is_classified_listing_kind(event.kind_u32()) { + return Err(RadrootsOperationalListingParseError::InvalidKind( + event.kind_u32(), + )); + } + operational_listing_from_event_parts(&event.tags_as_vec(), event.content()) +} + +fn decode_operational_listing_from_event_parts( + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsOperationalListing, OperationalListingDecodeError> { + reject_private_listing_location_content(content)?; + let d_tag = parse_d_tag(tags)?; + let farm_ref = parse_farm_ref(tags)?; + let farm_pubkey = parse_farm_pubkey(tags)?; + let resource_area = parse_resource_area(tags)?; + let plot = parse_plot_ref(tags)?; + + let mut product = RadrootsOperationalListingProduct { + key: String::new(), + title: String::new(), + category: String::new(), + summary: None, + process: None, + lot: None, + location: None, + profile: None, + year: None, + }; + let mut primary_bin_id: Option<String> = None; + let mut bin_drafts: Vec<BinDraft> = Vec::new(); + let mut bin_order = 0usize; + let mut discounts: Vec<RadrootsCoreDiscount> = Vec::new(); + let mut location: Option<OperationalListingLocationDraft> = None; + let mut inventory_available: Option<RadrootsCoreDecimal> = None; + let mut availability_status: Option<RadrootsOperationalListingStatus> = None; + let mut availability_start: Option<u64> = None; + let mut availability_end: Option<u64> = None; + let mut delivery_method: Option<RadrootsOperationalListingDeliveryMethod> = None; + let mut images: Vec<RadrootsOperationalListingImage> = Vec::new(); + let mut geohash: Option<String> = None; + let mut published_at: Option<u64> = None; + + for tag in tags { + if tag.is_empty() { + continue; + } + match tag[0].as_str() { + "key" => set_if_empty(&mut product.key, tag.get(1)), + "title" => set_if_empty(&mut product.title, tag.get(1)), + "category" => set_if_empty(&mut product.category, tag.get(1)), + "summary" => set_optional(&mut product.summary, tag.get(1)), + TAG_PUBLISHED_AT => { + published_at = Some(parse_u64_tag_value(tag.get(1), TAG_PUBLISHED_AT)?); + } + "process" => set_optional(&mut product.process, tag.get(1)), + "lot" => set_optional(&mut product.lot, tag.get(1)), + "location" => { + let parse_structured_location = !matches!(tag.len(), 0..=2); + if parse_structured_location { + let primary = tag + .get(1) + .and_then(|value| clean_value(value)) + .ok_or(OperationalListingDecodeError::InvalidTag(TAG_LOCATION))?; + let mut parsed = OperationalListingLocationDraft { + primary, + city: None, + region: None, + country: None, + }; + if let Some(city) = tag.get(2).and_then(|value| clean_value(value)) { + parsed.city = Some(city); + } + if let Some(region) = tag.get(3).and_then(|value| clean_value(value)) { + parsed.region = Some(region); + } + if let Some(country) = tag.get(4).and_then(|value| clean_value(value)) { + parsed.country = Some(country); + } + location = Some(parsed); + } else { + set_optional(&mut product.location, tag.get(1)); + } + } + "profile" => set_optional(&mut product.profile, tag.get(1)), + "year" => set_optional(&mut product.year, tag.get(1)), + TAG_PRICE => {} + TAG_RADROOTS_PRIMARY_BIN => { + let value = tag.get(1).and_then(|value| clean_value(value)).ok_or( + OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN), + )?; + if let Some(existing) = primary_bin_id.as_ref() { + if existing != &value { + return Err(OperationalListingDecodeError::InvalidTag( + TAG_RADROOTS_PRIMARY_BIN, + )); + } + } else { + primary_bin_id = Some(value); + } + } + TAG_RADROOTS_BIN => { + if tag.len() < 4 || tag.len() > 7 { + return Err(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_BIN)); + } + let bin_id = tag + .get(1) + .and_then(|value| clean_value(value)) + .ok_or(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_BIN))?; + let amount = parse_decimal(&tag[2], TAG_RADROOTS_BIN)?; + let unit = parse_unit(&tag[3], TAG_RADROOTS_BIN)?; + if unit != unit.canonical_unit() { + return Err(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_BIN)); + } + let bin = upsert_bin(&mut bin_drafts, &bin_id, &mut bin_order); + if bin.quantity.is_some() { + return Err(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_BIN)); + } + bin.quantity = Some(RadrootsCoreQuantity::new(amount, unit)); + + match tag.as_slice() { + [_, _, _, _, display_amount_raw, display_unit_raw] + | [_, _, _, _, display_amount_raw, display_unit_raw, _] => { + let display_amount = parse_decimal(display_amount_raw, TAG_RADROOTS_BIN)?; + let display_unit = parse_unit(display_unit_raw, TAG_RADROOTS_BIN)?; + bin.display_amount = Some(display_amount); + bin.display_unit = Some(display_unit); + if let [_, _, _, _, _, _, label] = tag.as_slice() { + bin.display_label = clean_value(label); + } + } + [_, _, _, _, _] => { + return Err(OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_BIN)); + } + _ => {} + } + } + TAG_RADROOTS_PRICE => { + if tag.len() < 6 || tag.len() > 8 { + return Err(OperationalListingDecodeError::InvalidTag( + TAG_RADROOTS_PRICE, + )); + } + let bin_id = tag.get(1).and_then(|value| clean_value(value)).ok_or( + OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PRICE), + )?; + let amount = parse_decimal(&tag[2], TAG_RADROOTS_PRICE)?; + let currency = parse_currency(&tag[3], TAG_RADROOTS_PRICE)?; + let per_amount = parse_decimal(&tag[4], TAG_RADROOTS_PRICE)?; + let per_unit = parse_unit(&tag[5], TAG_RADROOTS_PRICE)?; + let price_per_canonical_unit = RadrootsCoreQuantityPrice::new( + RadrootsCoreMoney::new(amount, currency), + RadrootsCoreQuantity::new(per_amount, per_unit), + ); + if !price_per_canonical_unit.is_price_per_canonical_unit() { + return Err(OperationalListingDecodeError::InvalidTag( + TAG_RADROOTS_PRICE, + )); + } + let bin = upsert_bin(&mut bin_drafts, &bin_id, &mut bin_order); + if bin.price_per_canonical_unit.is_some() { + return Err(OperationalListingDecodeError::InvalidTag( + TAG_RADROOTS_PRICE, + )); + } + bin.price_per_canonical_unit = Some(price_per_canonical_unit); + + match tag.as_slice() { + [_, _, _, _, _, _, _] => { + return Err(OperationalListingDecodeError::InvalidTag( + TAG_RADROOTS_PRICE, + )); + } + [_, _, _, _, _, _, display_price_raw, display_unit_raw] => { + let display_price = parse_decimal(display_price_raw, TAG_RADROOTS_PRICE)?; + let display_unit = parse_unit(display_unit_raw, TAG_RADROOTS_PRICE)?; + bin.display_price = Some(RadrootsCoreMoney::new(display_price, currency)); + bin.display_price_unit = Some(display_unit); + } + _ => {} + } + } + TAG_RADROOTS_DISCOUNT => { + let payload = tag.get(1).ok_or(OperationalListingDecodeError::InvalidTag( + TAG_RADROOTS_DISCOUNT, + ))?; + discounts.push(parse_discount(payload)?); + } + TAG_GEOHASH => { + let value = tag + .get(1) + .and_then(|value| clean_value(value)) + .ok_or(OperationalListingDecodeError::InvalidTag(TAG_GEOHASH))?; + if !is_public_geohash5(&value) { + return Err(OperationalListingDecodeError::InvalidTag(TAG_GEOHASH)); + } + let value = value.to_ascii_lowercase(); + if geohash.as_ref().is_some_and(|existing| existing != &value) { + return Err(OperationalListingDecodeError::InvalidTag(TAG_GEOHASH)); + } + geohash = Some(value); + } + TAG_DD => return Err(OperationalListingDecodeError::InvalidTag(TAG_DD)), + TAG_DD_LAT => return Err(OperationalListingDecodeError::InvalidTag(TAG_DD_LAT)), + TAG_DD_LON => return Err(OperationalListingDecodeError::InvalidTag(TAG_DD_LON)), + TAG_LABEL => return Err(OperationalListingDecodeError::InvalidTag(TAG_LABEL)), + TAG_LABEL_NS => return Err(OperationalListingDecodeError::InvalidTag(TAG_LABEL_NS)), + TAG_INVENTORY => { + let value = tag + .get(1) + .ok_or(OperationalListingDecodeError::InvalidTag(TAG_INVENTORY))?; + inventory_available = Some(parse_decimal(value, TAG_INVENTORY)?); + } + TAG_RADROOTS_AVAILABILITY_START => { + availability_start = Some(parse_u64_tag_value( + tag.get(1), + TAG_RADROOTS_AVAILABILITY_START, + )?); + } + TAG_EXPIRES_AT => { + availability_end = Some(parse_u64_tag_value(tag.get(1), TAG_EXPIRES_AT)?); + } + TAG_STATUS => { + let status = tag + .get(1) + .and_then(|value| clean_value(value)) + .unwrap_or_default(); + availability_status = Some(parse_status(&status)); + } + TAG_DELIVERY => { + let method = tag + .get(1) + .and_then(|value| clean_value(value)) + .unwrap_or_default(); + delivery_method = Some(match method.as_str() { + "pickup" => RadrootsOperationalListingDeliveryMethod::Pickup, + "local_delivery" => RadrootsOperationalListingDeliveryMethod::LocalDelivery, + "shipping" => RadrootsOperationalListingDeliveryMethod::Shipping, + "other" => RadrootsOperationalListingDeliveryMethod::Other { + method: tag + .get(2) + .and_then(|value| clean_value(value)) + .unwrap_or_default(), + }, + other => RadrootsOperationalListingDeliveryMethod::Other { + method: other.to_string(), + }, + }); + } + TAG_IMAGE => { + let url = tag + .get(1) + .ok_or(OperationalListingDecodeError::InvalidTag(TAG_IMAGE))?; + if url.trim().is_empty() { + continue; + } + images.push(RadrootsOperationalListingImage { + url: url.to_string(), + size: tag.get(2).and_then(|value| parse_image_size(value)), + }); + } + _ => {} + } + } + + let availability = match availability_status { + Some(status) => Some(RadrootsOperationalListingAvailability::Status { status }), + None => match (availability_start, availability_end) { + (None, None) => None, + (start, end) => Some(RadrootsOperationalListingAvailability::Window { start, end }), + }, + }; + + let location = location + .map(|location| { + let geohash = geohash.ok_or(OperationalListingDecodeError::InvalidTag(TAG_GEOHASH))?; + Ok::<RadrootsOperationalListingPublicLocation, OperationalListingDecodeError>( + RadrootsOperationalListingPublicLocation { + primary: location.primary, + city: location.city, + region: location.region, + country: location.country, + geohash, + }, + ) + }) + .transpose()?; + + if farm_pubkey != farm_ref.pubkey { + return Err(OperationalListingDecodeError::InvalidTag(TAG_P)); + } + + let primary_bin_id = primary_bin_id.ok_or(OperationalListingDecodeError::MissingTag( + TAG_RADROOTS_PRIMARY_BIN, + ))?; + let bins = build_bins(bin_drafts)?; + if !bins.iter().any(|bin| bin.bin_id == primary_bin_id) { + return Err(OperationalListingDecodeError::InvalidTag( + TAG_RADROOTS_PRIMARY_BIN, + )); + } + + let d_tag = RadrootsDTag::parse(&d_tag) + .map_err(|_| OperationalListingDecodeError::InvalidTag(TAG_D))?; + let primary_bin_id = RadrootsInventoryBinId::parse(&primary_bin_id) + .map_err(|_| OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN))?; + + Ok(RadrootsOperationalListing { + d_tag, + published_at, + farm: farm_ref, + product, + primary_bin_id, + bins, + resource_area, + plot, + discounts: if discounts.is_empty() { + None + } else { + Some(discounts) + }, + inventory_available, + availability, + delivery_method, + location, + images: if images.is_empty() { + None + } else { + Some(images) + }, + }) +} + +pub fn data_from_event( + id: String, + author: String, + published_at: u64, + kind: u32, + content: String, + tags: Vec<Vec<String>>, +) -> Result<RadrootsParsedData<RadrootsOperationalListing>, EventParseError> { + let listing = operational_listing_from_event(kind, &tags, &content)?; + Ok(RadrootsParsedData::new( + id, + author, + published_at, + kind, + listing, + )) +} + +pub fn parsed_from_event( + id: String, + author: String, + published_at: u64, + kind: u32, + content: String, + tags: Vec<Vec<String>>, + sig: String, +) -> Result<RadrootsParsedEvent<RadrootsOperationalListing>, EventParseError> { + let data = data_from_event( + id.clone(), + author.clone(), + published_at, + kind, + content.clone(), + tags.clone(), + )?; + RadrootsParsedEvent::from_parts( + id, + author, + published_at, + kind, + content, + tags, + sig, + data.data, + ) +} + +pub fn data_from_nostr_event( + event: &RadrootsEventEnvelope, +) -> Result<RadrootsParsedData<RadrootsOperationalListing>, EventParseError> { + data_from_event( + event.id_str().to_string(), + event.author_str().to_string(), + event.created_at_u64(), + event.kind_u32(), + event.content().to_string(), + event.tags_as_vec(), + ) +} + +pub fn parsed_from_nostr_event( + event: &RadrootsEventEnvelope, +) -> Result<RadrootsParsedEvent<RadrootsOperationalListing>, EventParseError> { + parsed_from_event( + event.id_str().to_string(), + event.author_str().to_string(), + event.created_at_u64(), + event.kind_u32(), + event.content().to_string(), + event.tags_as_vec(), + event.sig_str().to_string(), + ) +} + +fn clean_value(value: &str) -> Option<String> { + let trimmed = value.trim(); + if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("null") { + None + } else { + Some(trimmed.to_string()) + } +} + +fn set_if_empty(target: &mut String, value: Option<&String>) { + if target.trim().is_empty() + && let Some(value) = value.and_then(|value| clean_value(value)) + { + *target = value; + } +} + +fn set_optional(target: &mut Option<String>, value: Option<&String>) { + if target.is_none() + && let Some(value) = value.and_then(|value| clean_value(value)) + { + *target = Some(value); + } +} + +fn parse_status(value: &str) -> RadrootsOperationalListingStatus { + match value.trim().to_ascii_lowercase().as_str() { + "active" => RadrootsOperationalListingStatus::Active, + "sold" => RadrootsOperationalListingStatus::Sold, + other => RadrootsOperationalListingStatus::Other { + value: other.to_string(), + }, + } +} + +fn parse_image_size(value: &str) -> Option<RadrootsOperationalListingImageSize> { + let (w_raw, h_raw) = value.split_once('x')?; + let w = w_raw.parse::<u32>().ok()?; + let h = h_raw.parse::<u32>().ok()?; + Some(RadrootsOperationalListingImageSize { w, h }) +} + +fn parse_discount(payload: &str) -> Result<RadrootsCoreDiscount, OperationalListingDecodeError> { + #[cfg(feature = "serde_json")] + { + serde_json::from_str(payload) + .map_err(|_| OperationalListingDecodeError::InvalidDiscount(TAG_RADROOTS_DISCOUNT)) + } + #[cfg(not(feature = "serde_json"))] + { + let _ = payload; + Err(OperationalListingDecodeError::InvalidJson("discount")) + } +} + +#[derive(Clone, Debug)] +struct BinDraft { + bin_id: String, + order_index: usize, + quantity: Option<RadrootsCoreQuantity>, + display_amount: Option<RadrootsCoreDecimal>, + display_unit: Option<RadrootsCoreUnit>, + display_label: Option<String>, + price_per_canonical_unit: Option<RadrootsCoreQuantityPrice>, + display_price: Option<RadrootsCoreMoney>, + display_price_unit: Option<RadrootsCoreUnit>, +} + +fn upsert_bin<'a>( + bins: &'a mut Vec<BinDraft>, + bin_id: &str, + order_index: &mut usize, +) -> &'a mut BinDraft { + if let Some(position) = bins.iter().position(|bin| bin.bin_id == bin_id) { + return &mut bins[position]; + } + bins.push(BinDraft { + bin_id: bin_id.to_string(), + order_index: *order_index, + quantity: None, + display_amount: None, + display_unit: None, + display_label: None, + price_per_canonical_unit: None, + display_price: None, + display_price_unit: None, + }); + *order_index += 1; + let index = bins.len() - 1; + &mut bins[index] +} + +fn build_bins( + mut drafts: Vec<BinDraft>, +) -> Result<Vec<RadrootsOperationalListingBin>, OperationalListingDecodeError> { + drafts.sort_by_key(|draft| draft.order_index); + let mut bins = Vec::with_capacity(drafts.len()); + for draft in drafts { + let quantity = draft + .quantity + .ok_or(OperationalListingDecodeError::MissingTag(TAG_RADROOTS_BIN))?; + let price = + draft + .price_per_canonical_unit + .ok_or(OperationalListingDecodeError::MissingTag( + TAG_RADROOTS_PRICE, + ))?; + if quantity.unit != price.quantity.unit { + return Err(OperationalListingDecodeError::InvalidTag( + TAG_RADROOTS_PRICE, + )); + } + bins.push(RadrootsOperationalListingBin { + bin_id: RadrootsInventoryBinId::parse(&draft.bin_id) + .map_err(|_| OperationalListingDecodeError::InvalidTag(TAG_RADROOTS_BIN))?, + quantity, + price_per_canonical_unit: price, + display_amount: draft.display_amount, + display_unit: draft.display_unit, + display_label: draft.display_label, + display_price: draft.display_price, + display_price_unit: draft.display_price_unit, + }); + } + Ok(bins) +} diff --git a/crates/event_codec/src/operational_listing/encode.rs b/crates/event_codec/src/operational_listing/encode.rs @@ -0,0 +1,151 @@ +#[cfg(all(not(feature = "std"), test, feature = "serde_json"))] +use alloc::vec; +#[cfg(all(not(feature = "std"), feature = "serde_json"))] +use alloc::{format, string::ToString}; +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; + +#[cfg(feature = "serde_json")] +use radroots_event::kinds::{KIND_CLASSIFIED_LISTING, is_classified_listing_kind}; +use radroots_event::operational_listing::RadrootsOperationalListing; + +use crate::error::EventEncodeError; +use crate::operational_listing::tags::operational_listing_tags; +#[cfg(feature = "serde_json")] +use crate::operational_listing::tags::operational_listing_tags_full; +#[cfg(feature = "serde_json")] +use radroots_event::wire::RadrootsNip01EventWireParts; + +#[cfg(feature = "serde_json")] +const DEFAULT_KIND: u32 = KIND_CLASSIFIED_LISTING; + +pub fn operational_listing_build_tags( + listing: &RadrootsOperationalListing, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + operational_listing_tags(listing) +} + +#[cfg(feature = "serde_json")] +pub fn to_wire_parts( + listing: &RadrootsOperationalListing, +) -> Result<RadrootsNip01EventWireParts, EventEncodeError> { + to_wire_parts_with_kind(listing, DEFAULT_KIND) +} + +#[cfg(feature = "serde_json")] +pub fn to_wire_parts_with_kind( + listing: &RadrootsOperationalListing, + kind: u32, +) -> Result<RadrootsNip01EventWireParts, EventEncodeError> { + if !is_classified_listing_kind(kind) { + return Err(EventEncodeError::InvalidKind(kind)); + } + let tags = operational_listing_tags_full(listing)?; + let content = operational_listing_markdown_content(listing); + Ok(RadrootsNip01EventWireParts { + kind, + content, + tags, + }) +} + +#[cfg(feature = "serde_json")] +fn operational_listing_markdown_content(listing: &RadrootsOperationalListing) -> String { + let title = listing.product.title.trim(); + let summary = listing + .product + .summary + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + + match (title.is_empty(), summary) { + (false, Some(summary)) => format!("# {title}\n\n{summary}"), + (false, None) => format!("# {title}"), + (true, Some(summary)) => summary.to_string(), + (true, None) => String::new(), + } +} + +#[cfg(all(test, feature = "serde_json"))] +mod tests { + use super::*; + use core::str::FromStr; + use radroots_core::{ + RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, + RadrootsCoreQuantityPrice, RadrootsCoreUnit, + }; + use radroots_event::{ + farm::RadrootsFarmRef, + ids::{RadrootsDTag, RadrootsInventoryBinId}, + operational_listing::{RadrootsOperationalListingBin, RadrootsOperationalListingProduct}, + }; + + fn decimal(value: &str) -> RadrootsCoreDecimal { + RadrootsCoreDecimal::from_str(value).expect("decimal") + } + + fn listing_with(title: &str, summary: Option<&str>) -> RadrootsOperationalListing { + RadrootsOperationalListing { + d_tag: RadrootsDTag::parse("AAAAAAAAAAAAAAAAAAAAAA").expect("d tag"), + published_at: None, + farm: RadrootsFarmRef { + pubkey: "a".repeat(64), + d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".to_string(), + }, + product: RadrootsOperationalListingProduct { + key: "coffee".to_string(), + title: title.to_string(), + category: "produce".to_string(), + summary: summary.map(ToOwned::to_owned), + process: None, + lot: None, + location: None, + profile: None, + year: None, + }, + primary_bin_id: RadrootsInventoryBinId::parse("bin-1").expect("bin id"), + bins: vec![RadrootsOperationalListingBin { + bin_id: RadrootsInventoryBinId::parse("bin-1").expect("bin id"), + quantity: RadrootsCoreQuantity::new(decimal("1"), RadrootsCoreUnit::MassG), + price_per_canonical_unit: RadrootsCoreQuantityPrice::new( + RadrootsCoreMoney::new(decimal("1"), RadrootsCoreCurrency::USD), + RadrootsCoreQuantity::new(RadrootsCoreDecimal::ONE, RadrootsCoreUnit::MassG), + ), + display_amount: None, + display_unit: None, + display_label: None, + display_price: None, + display_price_unit: None, + }], + resource_area: None, + plot: None, + discounts: None, + inventory_available: None, + availability: None, + delivery_method: None, + location: None, + images: None, + } + } + + #[test] + fn operational_listing_markdown_content_covers_title_summary_combinations() { + assert_eq!( + operational_listing_markdown_content(&listing_with("Coffee", Some("Washed"))), + "# Coffee\n\nWashed" + ); + assert_eq!( + operational_listing_markdown_content(&listing_with("Coffee", None)), + "# Coffee" + ); + assert_eq!( + operational_listing_markdown_content(&listing_with(" ", Some("Washed"))), + "Washed" + ); + assert_eq!( + operational_listing_markdown_content(&listing_with(" ", None)), + "" + ); + } +} diff --git a/crates/event_codec/src/listing/mod.rs b/crates/event_codec/src/operational_listing/mod.rs diff --git a/crates/event_codec/src/operational_listing/tags.rs b/crates/event_codec/src/operational_listing/tags.rs @@ -0,0 +1,1363 @@ +#![forbid(unsafe_code)] + +#[cfg(not(feature = "std"))] +use alloc::{ + format, + string::{String, ToString}, + vec, + vec::Vec, +}; + +#[cfg(any(feature = "serde_json", test))] +use radroots_core::RadrootsCoreDiscount; +use radroots_core::RadrootsCoreMoney; +use radroots_event::farm::RadrootsFarmRef; +use radroots_event::kinds::{KIND_FARM, KIND_PLOT, KIND_RESOURCE_AREA}; +use radroots_event::location::{has_textual_locality, is_public_geohash5}; +use radroots_event::operational_listing::{ + RadrootsOperationalListing, RadrootsOperationalListingAvailability, + RadrootsOperationalListingBin, RadrootsOperationalListingDeliveryMethod, + RadrootsOperationalListingImage, RadrootsOperationalListingPublicLocation, + RadrootsOperationalListingStatus, +}; +use radroots_event::plot::RadrootsPlotRef; +use radroots_event::resource_area::RadrootsResourceAreaRef; +use radroots_event::tags::{TAG_D, TAG_PUBLISHED_AT}; + +use crate::d_tag::validate_d_tag; +use crate::error::EventEncodeError; + +const TAG_PRICE: &str = "price"; +const TAG_RADROOTS_BIN: &str = "radroots:bin"; +const TAG_RADROOTS_PRICE: &str = "radroots:price"; +#[cfg(feature = "serde_json")] +const TAG_RADROOTS_DISCOUNT: &str = "radroots:discount"; +const TAG_RADROOTS_PRIMARY_BIN: &str = "radroots:primary_bin"; +const TAG_RADROOTS_RESOURCE_AREA: &str = "radroots:resource_area"; +const TAG_RADROOTS_PLOT: &str = "radroots:plot"; +const TAG_LOCATION: &str = "location"; +const TAG_IMAGE: &str = "image"; +const TAG_GEOHASH: &str = "g"; +const TAG_INVENTORY: &str = "inventory"; +const TAG_DELIVERY: &str = "delivery"; +const TAG_RADROOTS_AVAILABILITY_START: &str = "radroots:availability_start"; +const TAG_STATUS: &str = "status"; +const TAG_EXPIRES_AT: &str = "expires_at"; +const TAG_P: &str = "p"; +const TAG_A: &str = "a"; + +#[derive(Clone, Copy, Debug)] +pub struct OperationalListingTagOptions { + pub include_geohash: bool, + pub include_inventory: bool, + pub include_availability: bool, + pub include_delivery: bool, +} + +impl Default for OperationalListingTagOptions { + fn default() -> Self { + Self { + include_geohash: true, + include_inventory: false, + include_availability: false, + include_delivery: false, + } + } +} + +impl OperationalListingTagOptions { + pub fn with_trade_fields() -> Self { + Self { + include_inventory: true, + include_availability: true, + include_delivery: true, + ..Self::default() + } + } +} + +pub fn operational_listing_tags( + listing: &RadrootsOperationalListing, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + operational_listing_tags_with_options(listing, OperationalListingTagOptions::default()) +} + +pub fn operational_listing_tags_full( + listing: &RadrootsOperationalListing, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + operational_listing_tags_with_options( + listing, + OperationalListingTagOptions::with_trade_fields(), + ) +} + +pub fn operational_listing_tags_with_options( + listing: &RadrootsOperationalListing, + options: OperationalListingTagOptions, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + let d_tag = listing.d_tag.as_str(); + validate_d_tag(d_tag, "d")?; + if listing.bins.is_empty() { + return Err(EventEncodeError::EmptyRequiredField("bins")); + } + + let mut tags: Vec<Vec<String>> = Vec::new(); + tags.push(vec![TAG_D.to_string(), d_tag.to_string()]); + push_farm_tags(&mut tags, &listing.farm)?; + if let Some(resource_area) = listing.resource_area.as_ref() { + tags.push(tag_listing_resource_area(resource_area)?); + } + if let Some(plot) = listing.plot.as_ref() { + tags.push(tag_listing_plot(plot)?); + } + + let product = &listing.product; + push_tag_value(&mut tags, "key", &product.key); + push_tag_value(&mut tags, "title", &product.title); + push_tag_value(&mut tags, "category", &product.category); + if let Some(summary) = product.summary.as_deref() { + push_tag_value(&mut tags, "summary", summary); + } + if let Some(published_at) = listing.published_at { + tags.push(vec![TAG_PUBLISHED_AT.to_string(), published_at.to_string()]); + } + if let Some(process) = product.process.as_deref() { + push_tag_value(&mut tags, "process", process); + } + if let Some(lot) = product.lot.as_deref() { + push_tag_value(&mut tags, "lot", lot); + } + if let Some(location) = product.location.as_deref() { + push_tag_value(&mut tags, "location", location); + } + if let Some(profile) = product.profile.as_deref() { + push_tag_value(&mut tags, "profile", profile); + } + if let Some(year) = product.year.as_deref() { + push_tag_value(&mut tags, "year", year); + } + + tags.push(vec![ + TAG_RADROOTS_PRIMARY_BIN.to_string(), + listing.primary_bin_id.to_string(), + ]); + + let mut bins: Vec<&RadrootsOperationalListingBin> = listing.bins.iter().collect(); + if let Some(pos) = bins + .iter() + .position(|bin| bin.bin_id == listing.primary_bin_id) + { + let primary = bins.remove(pos); + bins.insert(0, primary); + } + + for bin in bins { + let price_tag = tag_listing_price(bin)?; + let bin_tag = tag_listing_bin(bin)?; + tags.push(bin_tag); + tags.push(price_tag); + if bin.bin_id == listing.primary_bin_id { + let total = bin_total_price(bin)?; + tags.push(tag_listing_price_generic(&total)); + } + } + + #[cfg(feature = "serde_json")] + if let Some(discounts) = &listing.discounts { + for discount in discounts { + let payload = discount_tag_payload(discount)?; + tags.push(vec![TAG_RADROOTS_DISCOUNT.to_string(), payload]); + } + } + #[cfg(not(feature = "serde_json"))] + if listing.discounts.as_ref().is_some() { + return Err(EventEncodeError::Json); + } + + if options.include_inventory + && let Some(inventory) = &listing.inventory_available + { + tags.push(vec![TAG_INVENTORY.to_string(), inventory.to_string()]); + } + + if options.include_availability + && let Some(availability) = &listing.availability + { + match availability { + RadrootsOperationalListingAvailability::Status { status } => { + tags.push(vec![ + TAG_STATUS.to_string(), + status_as_str(status).to_string(), + ]); + } + RadrootsOperationalListingAvailability::Window { start, end } => { + if let Some(start) = start { + tags.push(vec![ + TAG_RADROOTS_AVAILABILITY_START.to_string(), + start.to_string(), + ]); + } + if let Some(end) = end { + tags.push(vec![TAG_EXPIRES_AT.to_string(), end.to_string()]); + } + } + } + } + + if options.include_delivery + && let Some(method) = &listing.delivery_method + { + let mut tag = Vec::with_capacity(3); + tag.push(TAG_DELIVERY.to_string()); + match method { + RadrootsOperationalListingDeliveryMethod::Pickup => tag.push("pickup".into()), + RadrootsOperationalListingDeliveryMethod::LocalDelivery => { + tag.push("local_delivery".into()) + } + RadrootsOperationalListingDeliveryMethod::Shipping => tag.push("shipping".into()), + RadrootsOperationalListingDeliveryMethod::Other { method } => { + tag.push("other".into()); + tag.push(method.clone()); + } + } + tags.push(tag); + } + + if let Some(location) = &listing.location { + let primary = clean_value(&location.primary) + .ok_or(EventEncodeError::EmptyRequiredField("location.primary"))?; + if !has_textual_locality( + &location.primary, + location.city.as_deref(), + location.region.as_deref(), + location.country.as_deref(), + ) { + return Err(EventEncodeError::EmptyRequiredField("location.locality")); + } + let mut tag = Vec::with_capacity(5); + tag.push(TAG_LOCATION.to_string()); + tag.push(primary); + if let Some(city) = location.city.as_deref().and_then(clean_value) { + tag.push(city); + } + if let Some(region) = location.region.as_deref().and_then(clean_value) { + tag.push(region); + } + if let Some(country) = location.country.as_deref().and_then(clean_value) { + tag.push(country); + } + tags.push(tag); + if options.include_geohash { + push_location_geotag(&mut tags, location)?; + } + } + + if let Some(images) = &listing.images { + for image in images { + if let Some(tag) = tag_listing_image(image) { + tags.push(tag); + } + } + } + + Ok(tags) +} + +fn push_farm_tags( + tags: &mut Vec<Vec<String>>, + farm: &RadrootsFarmRef, +) -> Result<(), EventEncodeError> { + if farm.pubkey.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("farm.pubkey")); + } + if farm.d_tag.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("farm.d_tag")); + } + validate_d_tag(&farm.d_tag, "farm.d_tag")?; + let mut address = String::new(); + address.push_str(&KIND_FARM.to_string()); + address.push(':'); + address.push_str(&farm.pubkey); + address.push(':'); + address.push_str(&farm.d_tag); + tags.push(vec![TAG_P.to_string(), farm.pubkey.clone()]); + tags.push(vec![TAG_A.to_string(), address]); + Ok(()) +} + +fn tag_listing_resource_area( + area: &RadrootsResourceAreaRef, +) -> Result<Vec<String>, EventEncodeError> { + if area.pubkey.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("resource_area.pubkey")); + } + if area.d_tag.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("resource_area.d_tag")); + } + validate_d_tag(&area.d_tag, "resource_area.d_tag")?; + let mut address = String::new(); + address.push_str(&KIND_RESOURCE_AREA.to_string()); + address.push(':'); + address.push_str(&area.pubkey); + address.push(':'); + address.push_str(&area.d_tag); + Ok(vec![TAG_RADROOTS_RESOURCE_AREA.to_string(), address]) +} + +fn tag_listing_plot(plot: &RadrootsPlotRef) -> Result<Vec<String>, EventEncodeError> { + if plot.pubkey.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("plot.pubkey")); + } + if plot.d_tag.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("plot.d_tag")); + } + validate_d_tag(&plot.d_tag, "plot.d_tag")?; + let mut address = String::new(); + address.push_str(&KIND_PLOT.to_string()); + address.push(':'); + address.push_str(&plot.pubkey); + address.push(':'); + address.push_str(&plot.d_tag); + Ok(vec![TAG_RADROOTS_PLOT.to_string(), address]) +} + +fn tag_listing_bin(bin: &RadrootsOperationalListingBin) -> Result<Vec<String>, EventEncodeError> { + let unit = bin.quantity.unit; + if unit != unit.canonical_unit() { + return Err(EventEncodeError::EmptyRequiredField("bin.quantity")); + } + let mut tag = Vec::with_capacity(7); + tag.push(TAG_RADROOTS_BIN.to_string()); + tag.push(bin.bin_id.to_string()); + tag.push(bin.quantity.amount.to_string()); + tag.push(unit.code().to_string()); + if let Some(amount) = bin.display_amount.as_ref() { + let Some(unit) = bin.display_unit else { + return Err(EventEncodeError::EmptyRequiredField("bin.display_unit")); + }; + tag.push(amount.to_string()); + tag.push(unit.code().to_string()); + if let Some(label) = bin + .display_label + .as_deref() + .and_then(clean_value) + .or_else(|| bin.quantity.label.as_deref().and_then(clean_value)) + { + tag.push(label); + } + } else if bin.display_unit.is_some() { + return Err(EventEncodeError::EmptyRequiredField("bin.display_amount")); + } + Ok(tag) +} + +fn tag_listing_price(bin: &RadrootsOperationalListingBin) -> Result<Vec<String>, EventEncodeError> { + let price = &bin.price_per_canonical_unit; + if !price.is_price_per_canonical_unit() { + return Err(EventEncodeError::EmptyRequiredField( + "bin.price_per_canonical_unit", + )); + } + let mut tag = Vec::with_capacity(8); + tag.push(TAG_RADROOTS_PRICE.to_string()); + tag.push(bin.bin_id.to_string()); + tag.push(price.amount.amount.to_string()); + tag.push(price.amount.currency.as_str().to_string()); + tag.push(price.quantity.amount.to_string()); + tag.push(price.quantity.unit.code().to_string()); + match (&bin.display_price, bin.display_price_unit) { + (Some(price_display), Some(unit)) => { + if price_display.currency != price.amount.currency { + return Err(EventEncodeError::EmptyRequiredField("bin.display_price")); + } + tag.push(price_display.amount.to_string()); + tag.push(unit.code().to_string()); + } + (None, None) => {} + (None, Some(_)) => return Err(EventEncodeError::EmptyRequiredField("bin.display_price")), + (Some(_), None) => { + return Err(EventEncodeError::EmptyRequiredField( + "bin.display_price_unit", + )); + } + } + Ok(tag) +} + +fn bin_total_price( + bin: &RadrootsOperationalListingBin, +) -> Result<RadrootsCoreMoney, EventEncodeError> { + bin.price_per_canonical_unit + .try_cost_for_quantity_in(&bin.quantity) + .map_err(|_| EventEncodeError::EmptyRequiredField("bin.price_per_canonical_unit")) +} + +fn tag_listing_price_generic(price: &RadrootsCoreMoney) -> Vec<String> { + let mut tag = Vec::with_capacity(4); + tag.push(TAG_PRICE.to_string()); + tag.push(price.amount.to_string()); + tag.push(price.currency.as_str().to_string()); + tag +} + +fn tag_listing_image(image: &RadrootsOperationalListingImage) -> Option<Vec<String>> { + let url = clean_value(&image.url)?; + let mut tag = Vec::with_capacity(3); + tag.push(TAG_IMAGE.to_string()); + tag.push(url); + if let Some(size) = &image.size { + tag.push(format!("{}x{}", size.w, size.h)); + } + Some(tag) +} + +fn push_location_geotag( + tags: &mut Vec<Vec<String>>, + location: &RadrootsOperationalListingPublicLocation, +) -> Result<(), EventEncodeError> { + let geohash = clean_value(&location.geohash) + .ok_or(EventEncodeError::EmptyRequiredField("location.geohash"))?; + if !is_public_geohash5(&geohash) { + return Err(EventEncodeError::InvalidField("location.geohash")); + } + tags.push(vec![TAG_GEOHASH.to_string(), geohash.to_ascii_lowercase()]); + Ok(()) +} + +fn push_tag_value(tags: &mut Vec<Vec<String>>, key: &str, value: &str) { + let _ = clean_value(value).map(|cleaned| tags.push(vec![key.to_string(), cleaned])); +} + +fn clean_value(value: &str) -> Option<String> { + let trimmed = value.trim(); + (!trimmed.is_empty() && !trimmed.eq_ignore_ascii_case("null")).then(|| trimmed.to_string()) +} + +#[cfg(any(feature = "serde_json", test))] +fn discount_tag_payload(discount: &RadrootsCoreDiscount) -> Result<String, EventEncodeError> { + #[cfg(feature = "serde_json")] + { + serde_json::to_string(discount).map_err(|_| EventEncodeError::Json) + } + #[cfg(not(feature = "serde_json"))] + { + let _ = discount; + Err(EventEncodeError::Json) + } +} + +fn status_as_str(status: &RadrootsOperationalListingStatus) -> &str { + match status { + RadrootsOperationalListingStatus::Active => "active", + RadrootsOperationalListingStatus::Sold => "sold", + RadrootsOperationalListingStatus::Other { value } => value.as_str(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use core::str::FromStr; + + use crate::test_fixtures::{ + CDN_PRIMARY_HTTPS, FIXTURE_ALICE_NPUB, FIXTURE_ALICE_PUBLIC_KEY_HEX, + }; + use radroots_core::{ + RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscountScope, + RadrootsCoreDiscountThreshold, RadrootsCoreDiscountValue, RadrootsCoreQuantity, + RadrootsCoreQuantityPrice, RadrootsCoreUnit, + }; + use radroots_event::ids::{RadrootsDTag, RadrootsInventoryBinId}; + use radroots_event::operational_listing::{ + RadrootsOperationalListingImageSize, RadrootsOperationalListingProduct, + RadrootsOperationalListingStatus, + }; + + const TEST_NPUB: &str = FIXTURE_ALICE_NPUB; + const TEST_PUBKEY_HEX: &str = FIXTURE_ALICE_PUBLIC_KEY_HEX; + const TEST_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAg"; + const TEST_FARM_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA"; + + fn cdn_url(path: &str) -> String { + format!("{CDN_PRIMARY_HTTPS}/{path}") + } + + fn decimal(value: &str) -> RadrootsCoreDecimal { + RadrootsCoreDecimal::from_str(value).expect("valid decimal") + } + + fn d_tag(raw: &str) -> RadrootsDTag { + raw.parse().unwrap() + } + + fn bin_id(raw: &str) -> RadrootsInventoryBinId { + raw.parse().unwrap() + } + + fn base_product() -> RadrootsOperationalListingProduct { + RadrootsOperationalListingProduct { + key: "coffee".to_string(), + title: "Coffee".to_string(), + category: "agri".to_string(), + summary: Some("summary".to_string()), + process: Some("washed".to_string()), + lot: Some("lot-1".to_string()), + location: Some("null".to_string()), + profile: Some("null".to_string()), + year: Some("2024".to_string()), + } + } + + fn base_bin() -> RadrootsOperationalListingBin { + RadrootsOperationalListingBin { + bin_id: bin_id("bin-1"), + quantity: RadrootsCoreQuantity::new(decimal("1000"), RadrootsCoreUnit::MassG) + .with_label("bag"), + price_per_canonical_unit: RadrootsCoreQuantityPrice::new( + RadrootsCoreMoney::new(decimal("0.01"), RadrootsCoreCurrency::USD), + RadrootsCoreQuantity::new(RadrootsCoreDecimal::ONE, RadrootsCoreUnit::MassG), + ), + display_amount: Some(decimal("1")), + display_unit: Some(RadrootsCoreUnit::MassKg), + display_label: Some("kilobag".to_string()), + display_price: Some(RadrootsCoreMoney::new( + decimal("10"), + RadrootsCoreCurrency::USD, + )), + display_price_unit: Some(RadrootsCoreUnit::MassKg), + } + } + + fn base_listing() -> RadrootsOperationalListing { + RadrootsOperationalListing { + d_tag: d_tag(TEST_D_TAG), + published_at: None, + farm: RadrootsFarmRef { + pubkey: TEST_PUBKEY_HEX.to_string(), + d_tag: TEST_FARM_D_TAG.to_string(), + }, + product: base_product(), + primary_bin_id: bin_id("bin-1"), + bins: vec![base_bin()], + resource_area: Some(RadrootsResourceAreaRef { + pubkey: TEST_PUBKEY_HEX.to_string(), + d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_string(), + }), + plot: Some(RadrootsPlotRef { + pubkey: TEST_PUBKEY_HEX.to_string(), + d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".to_string(), + }), + discounts: None, + inventory_available: Some(decimal("2")), + availability: Some(RadrootsOperationalListingAvailability::Window { + start: Some(10), + end: Some(20), + }), + delivery_method: Some(RadrootsOperationalListingDeliveryMethod::Pickup), + location: Some(RadrootsOperationalListingPublicLocation { + primary: "Moyobamba".to_string(), + city: Some("Moyobamba".to_string()), + region: Some("San Martin".to_string()), + country: Some("PE".to_string()), + geohash: "9q8yy".to_string(), + }), + images: Some(vec![ + RadrootsOperationalListingImage { + url: cdn_url("a.jpg"), + size: Some(RadrootsOperationalListingImageSize { w: 1200, h: 800 }), + }, + RadrootsOperationalListingImage { + url: " ".to_string(), + size: None, + }, + ]), + } + } + + fn find_tag<'a>(tags: &'a [Vec<String>], key: &str) -> Option<&'a Vec<String>> { + tags.iter() + .find(|tag| tag.first().map(|v| v.as_str()) == Some(key)) + } + + #[test] + fn options_defaults_and_trade_fields() { + let defaults = OperationalListingTagOptions::default(); + assert!(defaults.include_geohash); + assert!(!defaults.include_inventory); + assert!(!defaults.include_availability); + assert!(!defaults.include_delivery); + + let trade = OperationalListingTagOptions::with_trade_fields(); + assert!(trade.include_geohash); + assert!(trade.include_inventory); + assert!(trade.include_availability); + assert!(trade.include_delivery); + } + + #[test] + fn clean_value_and_push_tag_value_cover_null_paths() { + assert_eq!(clean_value(" value "), Some("value".to_string())); + assert_eq!(clean_value(""), None); + assert_eq!(clean_value(" null "), None); + + let mut tags = Vec::new(); + push_tag_value(&mut tags, "k", "value"); + push_tag_value(&mut tags, "k", "null"); + push_tag_value(&mut tags, "k", ""); + assert_eq!(tags.len(), 1); + assert_eq!(tags[0], vec!["k".to_string(), "value".to_string()]); + } + + #[test] + fn location_geotag_accepts_public_geohash5() { + let mut tags = Vec::new(); + let location = RadrootsOperationalListingPublicLocation { + primary: "Test".to_string(), + city: Some("Town".to_string()), + region: None, + country: None, + geohash: "9Q8YY".to_string(), + }; + push_location_geotag(&mut tags, &location).expect("public geohash"); + + assert_eq!(tags, vec![vec!["g".to_string(), "9q8yy".to_string()]]); + assert!(find_tag(&tags, "l").is_none()); + assert!(find_tag(&tags, "L").is_none()); + } + + #[test] + fn location_geotag_rejects_blank_geohash() { + let mut tags = Vec::new(); + let location = RadrootsOperationalListingPublicLocation { + primary: "Test".to_string(), + city: Some("Town".to_string()), + region: None, + country: None, + geohash: " ".to_string(), + }; + + assert!(matches!( + push_location_geotag(&mut tags, &location), + Err(EventEncodeError::EmptyRequiredField("location.geohash")) + )); + assert!(tags.is_empty()); + } + + #[test] + fn location_geotag_rejects_non_public_geohash() { + let mut tags = Vec::new(); + let location = RadrootsOperationalListingPublicLocation { + primary: "Test".to_string(), + city: Some("Town".to_string()), + region: None, + country: None, + geohash: "9q8yyz".to_string(), + }; + + assert!(matches!( + push_location_geotag(&mut tags, &location), + Err(EventEncodeError::InvalidField("location.geohash")) + )); + assert!(tags.is_empty()); + } + + #[test] + fn image_and_status_helpers_cover_variants() { + let with_size = tag_listing_image(&RadrootsOperationalListingImage { + url: format!(" {CDN_PRIMARY_HTTPS}/a.jpg "), + size: Some(RadrootsOperationalListingImageSize { w: 10, h: 20 }), + }) + .expect("image tag"); + assert_eq!(with_size[0], "image"); + assert_eq!(with_size[2], "10x20"); + + let without_size = tag_listing_image(&RadrootsOperationalListingImage { + url: cdn_url("b.jpg"), + size: None, + }) + .expect("image tag"); + assert_eq!(without_size.len(), 2); + + assert!( + tag_listing_image(&RadrootsOperationalListingImage { + url: "null".to_string(), + size: None, + }) + .is_none() + ); + + assert_eq!( + status_as_str(&RadrootsOperationalListingStatus::Active), + "active" + ); + assert_eq!( + status_as_str(&RadrootsOperationalListingStatus::Sold), + "sold" + ); + assert_eq!( + status_as_str(&RadrootsOperationalListingStatus::Other { + value: "paused".to_string() + }), + "paused" + ); + } + + #[test] + fn discount_payload_without_serde_json_errors() { + let discount = RadrootsCoreDiscount { + scope: RadrootsCoreDiscountScope::Bin, + threshold: RadrootsCoreDiscountThreshold::BinCount { + bin_id: "bin-1".to_string(), + min: 2, + }, + value: RadrootsCoreDiscountValue::MoneyPerBin(RadrootsCoreMoney::new( + decimal("1"), + RadrootsCoreCurrency::USD, + )), + }; + #[cfg(feature = "serde_json")] + { + let payload = discount_tag_payload(&discount).expect("serde_json payload"); + assert!(payload.contains("\"scope\":\"bin\"")); + } + #[cfg(not(feature = "serde_json"))] + { + let _err = discount_tag_payload(&discount).expect_err("missing serde_json"); + } + } + + #[test] + fn farm_and_reference_tag_helpers_cover_errors_and_success() { + let mut tags = Vec::new(); + push_farm_tags( + &mut tags, + &RadrootsFarmRef { + pubkey: TEST_PUBKEY_HEX.to_string(), + d_tag: TEST_FARM_D_TAG.to_string(), + }, + ) + .expect("farm tags"); + assert!(find_tag(&tags, "p").is_some()); + assert!(find_tag(&tags, "a").is_some()); + + let err = push_farm_tags( + &mut Vec::new(), + &RadrootsFarmRef { + pubkey: "".to_string(), + d_tag: TEST_FARM_D_TAG.to_string(), + }, + ) + .expect_err("empty farm pubkey"); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("farm.pubkey") + )); + + let err = push_farm_tags( + &mut Vec::new(), + &RadrootsFarmRef { + pubkey: TEST_PUBKEY_HEX.to_string(), + d_tag: "".to_string(), + }, + ) + .expect_err("empty farm d_tag"); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("farm.d_tag") + )); + + let err = push_farm_tags( + &mut Vec::new(), + &RadrootsFarmRef { + pubkey: TEST_PUBKEY_HEX.to_string(), + d_tag: "farm:invalid".to_string(), + }, + ) + .expect_err("invalid farm d_tag"); + assert!(matches!(err, EventEncodeError::InvalidField("farm.d_tag"))); + + let area = tag_listing_resource_area(&RadrootsResourceAreaRef { + pubkey: TEST_PUBKEY_HEX.to_string(), + d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_string(), + }) + .expect("resource area"); + assert_eq!(area[0], "radroots:resource_area"); + + let plot = tag_listing_plot(&RadrootsPlotRef { + pubkey: TEST_PUBKEY_HEX.to_string(), + d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".to_string(), + }) + .expect("plot"); + assert_eq!(plot[0], "radroots:plot"); + } + + #[test] + fn bin_tag_and_price_helpers_cover_error_and_success_paths() { + let bin = base_bin(); + let bin_tag = tag_listing_bin(&bin).expect("bin tag"); + assert_eq!(bin_tag[0], "radroots:bin"); + let price_tag = tag_listing_price(&bin).expect("price tag"); + assert_eq!(price_tag[0], "radroots:price"); + let total = bin_total_price(&bin).expect("total price"); + let generic_tag = tag_listing_price_generic(&total); + assert_eq!(generic_tag[0], "price"); + + let mut non_canonical = base_bin(); + non_canonical.quantity = RadrootsCoreQuantity::new(decimal("1"), RadrootsCoreUnit::MassKg); + let err = tag_listing_bin(&non_canonical).expect_err("non canonical quantity"); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("bin.quantity") + )); + + let mut missing_display_amount = base_bin(); + missing_display_amount.display_amount = None; + let err = tag_listing_bin(&missing_display_amount).expect_err("missing display amount"); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("bin.display_amount") + )); + + let mut missing_display_unit = base_bin(); + missing_display_unit.display_unit = None; + let err = tag_listing_bin(&missing_display_unit).expect_err("missing display unit"); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("bin.display_unit") + )); + + let mut no_display_bin = base_bin(); + no_display_bin.display_amount = None; + no_display_bin.display_unit = None; + no_display_bin.display_label = None; + let no_display_tag = tag_listing_bin(&no_display_bin).expect("bin tag without display"); + assert_eq!(no_display_tag.len(), 4); + + let mut invalid_unit_price = base_bin(); + invalid_unit_price.price_per_canonical_unit = RadrootsCoreQuantityPrice::new( + RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD), + RadrootsCoreQuantity::new(decimal("2"), RadrootsCoreUnit::MassG), + ); + let err = tag_listing_price(&invalid_unit_price).expect_err("not unit price"); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("bin.price_per_canonical_unit") + )); + + let mut mismatch_display_currency = base_bin(); + mismatch_display_currency.display_price = Some(RadrootsCoreMoney::new( + decimal("10"), + RadrootsCoreCurrency::EUR, + )); + let err = tag_listing_price(&mismatch_display_currency).expect_err("currency mismatch"); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("bin.display_price") + )); + + let mut missing_display_price = base_bin(); + missing_display_price.display_price = None; + let err = tag_listing_price(&missing_display_price).expect_err("missing display price"); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("bin.display_price") + )); + + let mut missing_display_price_unit = base_bin(); + missing_display_price_unit.display_price_unit = None; + let err = tag_listing_price(&missing_display_price_unit).expect_err("missing unit"); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("bin.display_price_unit") + )); + + let mut no_display_price = base_bin(); + no_display_price.display_price = None; + no_display_price.display_price_unit = None; + let tag = tag_listing_price(&no_display_price).expect("price tag without display fields"); + let full_tag = tag_listing_price(&base_bin()).expect("price tag with display fields"); + assert_eq!(&tag[..6], &full_tag[..6]); + assert_eq!(tag.len(), 6); + + let mut invalid_cost = base_bin(); + invalid_cost.price_per_canonical_unit = RadrootsCoreQuantityPrice::new( + RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD), + RadrootsCoreQuantity::new(RadrootsCoreDecimal::ONE, RadrootsCoreUnit::Each), + ); + let err = bin_total_price(&invalid_cost).expect_err("invalid cost conversion"); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("bin.price_per_canonical_unit") + )); + } + + #[test] + fn operational_listing_tags_propagate_bin_and_resource_area_validation_errors() { + let mut listing = base_listing(); + listing.discounts = None; + listing.bins[0].price_per_canonical_unit = RadrootsCoreQuantityPrice::new( + RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD), + RadrootsCoreQuantity::new(decimal("2"), RadrootsCoreUnit::MassG), + ); + let err = operational_listing_tags_with_options( + &listing, + OperationalListingTagOptions::default(), + ) + .expect_err("non unit price should fail listing tags"); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("bin.price_per_canonical_unit") + )); + + let mut listing = base_listing(); + listing.discounts = None; + listing.bins[0].quantity = + RadrootsCoreQuantity::new(decimal("1"), RadrootsCoreUnit::MassKg); + listing.bins[0].price_per_canonical_unit = RadrootsCoreQuantityPrice::new( + RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD), + RadrootsCoreQuantity::new(RadrootsCoreDecimal::ONE, RadrootsCoreUnit::MassG), + ); + let err = operational_listing_tags_with_options( + &listing, + OperationalListingTagOptions::default(), + ) + .expect_err("non-canonical quantity should fail listing tags"); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("bin.quantity") + )); + + let mut listing = base_listing(); + listing.discounts = None; + listing.bins[0].quantity = + RadrootsCoreQuantity::new(RadrootsCoreDecimal::ONE, RadrootsCoreUnit::Each); + listing.bins[0].price_per_canonical_unit = RadrootsCoreQuantityPrice::new( + RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD), + RadrootsCoreQuantity::new(RadrootsCoreDecimal::ONE, RadrootsCoreUnit::MassG), + ); + let err = operational_listing_tags_with_options( + &listing, + OperationalListingTagOptions::default(), + ) + .expect_err("invalid total conversion should fail listing tags"); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("bin.price_per_canonical_unit") + )); + + let mut listing = base_listing(); + listing.discounts = None; + listing.resource_area = Some(RadrootsResourceAreaRef { + pubkey: TEST_PUBKEY_HEX.to_string(), + d_tag: "invalid".to_string(), + }); + let err = operational_listing_tags_with_options( + &listing, + OperationalListingTagOptions::default(), + ) + .expect_err("invalid resource area d_tag should fail listing tags"); + assert!(matches!( + err, + EventEncodeError::InvalidField("resource_area.d_tag") + )); + } + + #[test] + fn operational_listing_tags_required_errors_and_success_paths() { + let mut listing_with_discount = base_listing(); + listing_with_discount.discounts = Some(vec![RadrootsCoreDiscount { + scope: RadrootsCoreDiscountScope::Bin, + threshold: RadrootsCoreDiscountThreshold::BinCount { + bin_id: "bin-1".to_string(), + min: 2, + }, + value: RadrootsCoreDiscountValue::MoneyPerBin(RadrootsCoreMoney::new( + decimal("1"), + RadrootsCoreCurrency::USD, + )), + }]); + #[cfg(feature = "serde_json")] + { + let tags = operational_listing_tags_with_options( + &listing_with_discount, + OperationalListingTagOptions::default(), + ) + .expect("discount serialization works"); + assert!(find_tag(&tags, "radroots:discount").is_some()); + } + #[cfg(not(feature = "serde_json"))] + { + let _err = operational_listing_tags_with_options( + &listing_with_discount, + OperationalListingTagOptions::default(), + ) + .expect_err("discount serialization requires serde_json"); + } + + let mut listing = base_listing(); + listing.discounts = None; + let tags = operational_listing_tags_with_options( + &listing, + OperationalListingTagOptions::with_trade_fields(), + ) + .expect("listing tags"); + assert!(find_tag(&tags, "d").is_some()); + assert!(find_tag(&tags, "p").is_some()); + assert!(find_tag(&tags, "a").is_some()); + assert!(find_tag(&tags, "radroots:primary_bin").is_some()); + assert!(find_tag(&tags, "radroots:bin").is_some()); + assert!(find_tag(&tags, "radroots:price").is_some()); + assert!(find_tag(&tags, "price").is_some()); + assert!(find_tag(&tags, "inventory").is_some()); + assert!(find_tag(&tags, "radroots:availability_start").is_some()); + assert!(find_tag(&tags, "expires_at").is_some()); + assert!(find_tag(&tags, "delivery").is_some()); + assert!(find_tag(&tags, "location").is_some()); + assert!(find_tag(&tags, "image").is_some()); + + let mut status_listing = base_listing(); + status_listing.discounts = None; + status_listing.availability = Some(RadrootsOperationalListingAvailability::Status { + status: RadrootsOperationalListingStatus::Other { + value: "paused".to_string(), + }, + }); + let status_tags = operational_listing_tags_full(&status_listing).expect("status tags"); + assert!(status_tags.iter().any(|tag| { + tag.first().map(|v| v.as_str()) == Some("status") + && tag.get(1).map(|v| v.as_str()) == Some("paused") + })); + + for method in [ + RadrootsOperationalListingDeliveryMethod::Pickup, + RadrootsOperationalListingDeliveryMethod::LocalDelivery, + RadrootsOperationalListingDeliveryMethod::Shipping, + RadrootsOperationalListingDeliveryMethod::Other { + method: "scheduled".to_string(), + }, + ] { + let mut delivery_listing = base_listing(); + delivery_listing.discounts = None; + delivery_listing.delivery_method = Some(method); + let method_tags = + operational_listing_tags_full(&delivery_listing).expect("delivery tags"); + assert!(find_tag(&method_tags, "delivery").is_some()); + } + + let mut no_availability = base_listing(); + no_availability.discounts = None; + no_availability.availability = None; + let no_availability_tags = operational_listing_tags_with_options( + &no_availability, + OperationalListingTagOptions { + include_availability: true, + ..OperationalListingTagOptions::default() + }, + ) + .expect("availability option without value"); + assert!(find_tag(&no_availability_tags, "status").is_none()); + assert!(find_tag(&no_availability_tags, "radroots:availability_start").is_none()); + assert!(find_tag(&no_availability_tags, "expires_at").is_none()); + + let mut empty_window_availability = base_listing(); + empty_window_availability.discounts = None; + empty_window_availability.availability = + Some(RadrootsOperationalListingAvailability::Window { + start: None, + end: None, + }); + let empty_window_tags = operational_listing_tags_with_options( + &empty_window_availability, + OperationalListingTagOptions { + include_availability: true, + ..OperationalListingTagOptions::default() + }, + ) + .expect("availability window without bounds"); + assert!(find_tag(&empty_window_tags, "radroots:availability_start").is_none()); + assert!(find_tag(&empty_window_tags, "expires_at").is_none()); + + let mut no_delivery = base_listing(); + no_delivery.discounts = None; + no_delivery.delivery_method = None; + let no_delivery_tags = operational_listing_tags_with_options( + &no_delivery, + OperationalListingTagOptions { + include_delivery: true, + ..OperationalListingTagOptions::default() + }, + ) + .expect("delivery option without value"); + assert!(find_tag(&no_delivery_tags, "delivery").is_none()); + + let mut no_inventory = base_listing(); + no_inventory.discounts = None; + no_inventory.inventory_available = None; + let no_inventory_tags = operational_listing_tags_with_options( + &no_inventory, + OperationalListingTagOptions { + include_inventory: true, + ..OperationalListingTagOptions::default() + }, + ) + .expect("inventory option without value"); + assert!(find_tag(&no_inventory_tags, "inventory").is_none()); + + let mut no_geo = base_listing(); + no_geo.discounts = None; + let no_geo_tags = operational_listing_tags_with_options( + &no_geo, + OperationalListingTagOptions { + include_geohash: false, + ..OperationalListingTagOptions::default() + }, + ) + .expect("location without geotags"); + assert!(find_tag(&no_geo_tags, "location").is_some()); + assert!(find_tag(&no_geo_tags, "g").is_none()); + assert!(find_tag(&no_geo_tags, "l").is_none()); + + let geohash_only_tags = operational_listing_tags_with_options( + &no_geo, + OperationalListingTagOptions { + include_geohash: true, + ..OperationalListingTagOptions::default() + }, + ) + .expect("location with geohash only"); + assert!(find_tag(&geohash_only_tags, "g").is_some()); + assert!(find_tag(&geohash_only_tags, "l").is_none()); + } + + #[test] + fn operational_listing_tags_required_field_errors() { + let mut listing = base_listing(); + + listing.d_tag = d_tag("listing:invalid"); + let err = operational_listing_tags(&listing).expect_err("invalid d"); + assert!(matches!(err, EventEncodeError::InvalidField("d"))); + + listing = base_listing(); + listing.bins.clear(); + let err = operational_listing_tags(&listing).expect_err("missing bins"); + assert!(matches!(err, EventEncodeError::EmptyRequiredField("bins"))); + + listing = base_listing(); + listing.farm.pubkey = "".to_string(); + let err = operational_listing_tags(&listing).expect_err("missing farm pubkey"); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("farm.pubkey") + )); + + listing = base_listing(); + listing.resource_area = Some(RadrootsResourceAreaRef { + pubkey: "".to_string(), + d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_string(), + }); + let err = operational_listing_tags(&listing).expect_err("missing resource_area pubkey"); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("resource_area.pubkey") + )); + + listing = base_listing(); + listing.resource_area = Some(RadrootsResourceAreaRef { + pubkey: TEST_PUBKEY_HEX.to_string(), + d_tag: "".to_string(), + }); + let err = operational_listing_tags(&listing).expect_err("missing resource_area d_tag"); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("resource_area.d_tag") + )); + + listing = base_listing(); + listing.plot = Some(RadrootsPlotRef { + pubkey: "".to_string(), + d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".to_string(), + }); + let err = operational_listing_tags(&listing).expect_err("missing plot pubkey"); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("plot.pubkey") + )); + + listing = base_listing(); + listing.plot = Some(RadrootsPlotRef { + pubkey: TEST_PUBKEY_HEX.to_string(), + d_tag: "".to_string(), + }); + let err = operational_listing_tags(&listing).expect_err("missing plot d_tag"); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("plot.d_tag") + )); + + listing = base_listing(); + listing.plot = Some(RadrootsPlotRef { + pubkey: TEST_PUBKEY_HEX.to_string(), + d_tag: "plot:invalid".to_string(), + }); + let err = operational_listing_tags(&listing).expect_err("invalid plot d_tag"); + assert!(matches!(err, EventEncodeError::InvalidField("plot.d_tag"))); + } + + #[test] + fn operational_listing_tags_omit_optional_refs_and_product_fields_when_absent() { + let mut listing = base_listing(); + listing.discounts = None; + listing.resource_area = None; + listing.plot = None; + listing.product.summary = None; + listing.product.process = None; + listing.product.lot = None; + listing.product.location = None; + listing.product.profile = None; + listing.product.year = None; + listing.location = None; + listing.images = None; + + let tags = + operational_listing_tags(&listing).expect("listing tags without optional fields"); + assert!(find_tag(&tags, "d").is_some()); + assert!(find_tag(&tags, "p").is_some()); + assert!(find_tag(&tags, "a").is_some()); + assert!(find_tag(&tags, "radroots:resource_area").is_none()); + assert!(find_tag(&tags, "radroots:plot").is_none()); + assert!(find_tag(&tags, "summary").is_none()); + assert!(find_tag(&tags, "process").is_none()); + assert!(find_tag(&tags, "lot").is_none()); + assert!(find_tag(&tags, "location").is_none()); + assert!(find_tag(&tags, "profile").is_none()); + assert!(find_tag(&tags, "year").is_none()); + assert!(find_tag(&tags, "image").is_none()); + } + + #[test] + fn operational_listing_tags_location_and_product_cleaning_paths() { + let mut listing = base_listing(); + listing.discounts = None; + listing.product.location = Some(" null ".to_string()); + listing.product.profile = Some(" ".to_string()); + listing.location = None; + listing.images = Some(vec![RadrootsOperationalListingImage { + url: "null".to_string(), + size: None, + }]); + let tags = operational_listing_tags_full(&listing).expect("cleaning path tags"); + assert!(find_tag(&tags, "location").is_none()); + assert!(find_tag(&tags, "profile").is_none()); + assert!(find_tag(&tags, "image").is_none()); + } + + #[test] + fn operational_listing_tags_preserve_location_fields_when_present() { + let mut listing = base_listing(); + listing.discounts = None; + listing.images = None; + listing.location = Some(RadrootsOperationalListingPublicLocation { + primary: "Moyobamba".to_string(), + city: Some("Moyobamba".to_string()), + region: Some("San Martin".to_string()), + country: Some("PE".to_string()), + geohash: "6gkzw".to_string(), + }); + let tags = operational_listing_tags_full(&listing).expect("location tags"); + let location = find_tag(&tags, "location").expect("location tag"); + assert_eq!(location.get(1).map(|v| v.as_str()), Some("Moyobamba")); + assert_eq!(location.get(2).map(|v| v.as_str()), Some("Moyobamba")); + assert_eq!(location.get(3).map(|v| v.as_str()), Some("San Martin")); + assert_eq!(location.get(4).map(|v| v.as_str()), Some("PE")); + assert!(find_tag(&tags, "image").is_none()); + } + + #[test] + fn operational_listing_tags_reorders_primary_bin_and_falls_back_to_quantity_label() { + let mut listing = base_listing(); + listing.discounts = None; + + let mut primary = base_bin(); + primary.bin_id = bin_id("bin-1"); + primary.display_label = None; + primary.quantity = primary.quantity.clone().with_label("fallback-label"); + + let mut secondary = base_bin(); + secondary.bin_id = bin_id("bin-2"); + listing.primary_bin_id = bin_id("bin-1"); + listing.bins = vec![secondary, primary]; + + let tags = operational_listing_tags(&listing).expect("listing tags"); + let first_bin = tags + .iter() + .find(|tag| tag.first().map(|v| v.as_str()) == Some("radroots:bin")) + .expect("first bin tag"); + assert_eq!(first_bin.get(1).map(|v| v.as_str()), Some("bin-1")); + assert_eq!(first_bin.get(6).map(|v| v.as_str()), Some("fallback-label")); + + let mut listing_without_primary_match = base_listing(); + listing_without_primary_match.discounts = None; + let mut first = base_bin(); + first.bin_id = bin_id("bin-2"); + first.display_label = None; + first.quantity = RadrootsCoreQuantity::new(decimal("1000"), RadrootsCoreUnit::MassG); + let mut second = base_bin(); + second.bin_id = bin_id("bin-1"); + listing_without_primary_match.primary_bin_id = bin_id("bin-missing"); + listing_without_primary_match.bins = vec![first, second]; + + let tags = operational_listing_tags(&listing_without_primary_match).expect("listing tags"); + let first_bin = tags + .iter() + .find(|tag| tag.first().map(|v| v.as_str()) == Some("radroots:bin")) + .expect("first bin tag"); + assert_eq!(first_bin.get(1).map(|v| v.as_str()), Some("bin-2")); + assert_eq!(first_bin.len(), 6); + } + + #[test] + fn operational_listing_tags_location_handles_partial_optional_components() { + let mut listing = base_listing(); + listing.discounts = None; + listing.location = Some(RadrootsOperationalListingPublicLocation { + primary: "Moyobamba".to_string(), + city: Some(" ".to_string()), + region: Some("San Martin".to_string()), + country: Some(" ".to_string()), + geohash: "9q8yy".to_string(), + }); + + let tags = operational_listing_tags(&listing).expect("listing tags"); + let location = find_tag(&tags, "location").expect("location tag"); + assert_eq!(location.first().map(|v| v.as_str()), Some("location")); + assert_eq!(location.get(1).map(|v| v.as_str()), Some("Moyobamba")); + assert_eq!(location.get(2).map(|v| v.as_str()), Some("San Martin")); + assert_eq!(location.len(), 3); + + listing.location = Some(RadrootsOperationalListingPublicLocation { + primary: "Moyobamba".to_string(), + city: Some("Moyobamba".to_string()), + region: Some(" ".to_string()), + country: Some("PE".to_string()), + geohash: "9q8yy".to_string(), + }); + let tags = operational_listing_tags(&listing).expect("listing tags"); + let location = find_tag(&tags, "location").expect("location tag"); + assert_eq!(location.get(1).map(|v| v.as_str()), Some("Moyobamba")); + assert_eq!(location.get(2).map(|v| v.as_str()), Some("Moyobamba")); + assert_eq!(location.get(3).map(|v| v.as_str()), Some("PE")); + assert_eq!(location.len(), 4); + } + + #[test] + fn operational_listing_tags_supports_npub_farm_pubkey() { + let mut listing = base_listing(); + listing.discounts = None; + listing.farm.pubkey = TEST_NPUB.to_string(); + let tags = operational_listing_tags(&listing).expect("npub farm tags"); + assert!(tags.iter().any(|tag| { + tag.first().map(|v| v.as_str()) == Some("p") + && tag.get(1).map(|v| v.as_str()) == Some(TEST_NPUB) + })); + } +} diff --git a/crates/event_codec/src/order/decode.rs b/crates/event_codec/src/order/decode.rs @@ -4,7 +4,9 @@ use alloc::{string::String, vec::Vec}; #[cfg(feature = "serde_json")] use radroots_event::{ RadrootsEventEnvelope, RadrootsEventPtr, - ids::{RadrootsEventId, RadrootsIdParseError, RadrootsListingAddress, RadrootsPublicKey}, + ids::{ + RadrootsClassifiedListingAddress, RadrootsEventId, RadrootsIdParseError, RadrootsPublicKey, + }, kinds::is_order_event_kind, order::{ RadrootsOrderCancellation, RadrootsOrderDecision, RadrootsOrderEnvelope, @@ -124,7 +126,7 @@ pub fn order_envelope_from_event<T: DeserializeOwned>( if envelope.listing_addr != listing_addr { return Err(RadrootsOrderEnvelopeParseError::ListingAddrTagMismatch); } - RadrootsListingAddress::parse(&envelope.listing_addr) + RadrootsClassifiedListingAddress::parse(&envelope.listing_addr) .map_err(RadrootsOrderEnvelopeParseError::InvalidListingAddr)?; let tag_order_id = required_order_tag_value(&event_tags, TAG_D)?; @@ -354,8 +356,8 @@ mod tests { use radroots_event::{ RadrootsEventEnvelope, RadrootsEventEnvelopeParts, RadrootsEventPtr, ids::{ - RadrootsEventId, RadrootsInventoryBinId, RadrootsListingAddress, RadrootsOrderId, - RadrootsOrderQuoteId, RadrootsPublicKey, + RadrootsClassifiedListingAddress, RadrootsEventId, RadrootsInventoryBinId, + RadrootsOrderId, RadrootsOrderQuoteId, RadrootsPublicKey, }, kinds::{KIND_ORDER_CANCELLATION, KIND_ORDER_DECISION, KIND_ORDER_REQUEST}, order::{ @@ -391,7 +393,7 @@ mod tests { seller_pubkey().into_string() } - fn listing_addr() -> RadrootsListingAddress { + fn listing_addr() -> RadrootsClassifiedListingAddress { format!("30402:{}:AAAAAAAAAAAAAAAAAAAAAg", seller_pubkey_wire()) .parse() .unwrap() @@ -564,7 +566,7 @@ mod tests { #[test] fn listing_address_roundtrips() { let raw = format!("30402:{}:listing-1", seller_pubkey_wire()); - let addr = RadrootsListingAddress::parse(&raw).expect("parse listing address"); + let addr = RadrootsClassifiedListingAddress::parse(&raw).expect("parse listing address"); assert_eq!(addr.as_str(), raw); } @@ -752,7 +754,7 @@ mod tests { RadrootsOrderPayloadError::MissingItems, ); let invalid_listing_addr = RadrootsOrderEnvelopeParseError::InvalidListingAddr( - RadrootsListingAddress::parse("not-a-listing-address").unwrap_err(), + RadrootsClassifiedListingAddress::parse("not-a-listing-address").unwrap_err(), ); let errors = [ RadrootsOrderEnvelopeParseError::InvalidKind(3431), diff --git a/crates/event_codec/src/order/tags.rs b/crates/event_codec/src/order/tags.rs @@ -254,7 +254,7 @@ mod tests { }; use radroots_event::{ RadrootsEventPtr, - kinds::KIND_LISTING, + kinds::KIND_CLASSIFIED_LISTING, tags::{TAG_D, TAG_E_PREV, TAG_E_ROOT}, }; @@ -264,7 +264,7 @@ mod tests { #[test] fn order_envelope_tags_build_expected_tags() { - let listing_addr = format!("{KIND_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg"); + let listing_addr = format!("{KIND_CLASSIFIED_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg"); let tags = order_envelope_tags( "pubkey", listing_addr.as_str(), @@ -284,7 +284,7 @@ mod tests { #[test] fn order_envelope_tags_include_listing_event_pointer_and_chain_refs() { - let listing_addr = format!("{KIND_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg"); + let listing_addr = format!("{KIND_CLASSIFIED_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg"); let listing_event_id = event_id('a'); let tags = order_envelope_tags( "buyer", @@ -318,7 +318,7 @@ mod tests { #[test] fn order_envelope_tags_support_listing_event_without_relay() { - let listing_addr = format!("{KIND_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg"); + let listing_addr = format!("{KIND_CLASSIFIED_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg"); let listing_event_id = event_id('b'); let tags = order_envelope_tags( "buyer", @@ -345,7 +345,7 @@ mod tests { #[test] fn order_envelope_tags_accept_str_listing_address() { - let listing_addr = format!("{KIND_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg"); + let listing_addr = format!("{KIND_CLASSIFIED_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg"); let tags = order_envelope_tags( "buyer", listing_addr.as_str(), @@ -369,7 +369,7 @@ mod tests { #[test] fn order_envelope_tags_accept_str_listing_address_with_listing_event_only() { - let listing_addr = format!("{KIND_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg"); + let listing_addr = format!("{KIND_CLASSIFIED_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg"); let listing_event_id = event_id('c'); let tags = order_envelope_tags( "buyer", @@ -395,7 +395,7 @@ mod tests { #[test] fn order_envelope_tags_reject_empty_required_fields() { - let listing_addr = format!("{KIND_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg"); + let listing_addr = format!("{KIND_CLASSIFIED_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg"); let err = order_envelope_tags(" ", listing_addr.as_str(), None::<&str>, None, None, None) .expect_err("blank recipient"); diff --git a/crates/event_codec/src/tag_builders.rs b/crates/event_codec/src/tag_builders.rs @@ -15,10 +15,10 @@ use radroots_event::{ document::RadrootsDocument, farm::RadrootsFarm, follow::RadrootsFollow, geochat::RadrootsGeoChat, gift_wrap::RadrootsGiftWrap, job_feedback::RadrootsJobFeedback, job_request::RadrootsJobRequest, job_result::RadrootsJobResult, list::RadrootsList, - list_set::RadrootsListSet, listing::RadrootsListing, message::RadrootsMessage, - message_file::RadrootsMessageFile, plot::RadrootsPlot, reaction::RadrootsReaction, - resource_area::RadrootsResourceArea, resource_cap::RadrootsResourceHarvestCap, - seal::RadrootsSeal, + list_set::RadrootsListSet, message::RadrootsMessage, message_file::RadrootsMessageFile, + operational_listing::RadrootsOperationalListing, plot::RadrootsPlot, + reaction::RadrootsReaction, resource_area::RadrootsResourceArea, + resource_cap::RadrootsResourceHarvestCap, seal::RadrootsSeal, }; use crate::app_data::encode::app_data_build_tags; @@ -44,9 +44,9 @@ use crate::knowledge::encode::{ }; use crate::list::encode::list_build_tags; use crate::list_set::encode::list_set_build_tags; -use crate::listing::tags::listing_tags; use crate::message::encode::message_build_tags; use crate::message_file::encode::message_file_build_tags; +use crate::operational_listing::tags::operational_listing_tags; use crate::plot::encode::plot_build_tags; use crate::reaction::encode::reaction_build_tags; use crate::resource_area::encode::resource_area_build_tags; @@ -58,11 +58,11 @@ pub trait RadrootsEventTagBuilder { fn build_tags(&self) -> Result<Vec<Vec<String>>, Self::Error>; } -impl RadrootsEventTagBuilder for RadrootsListing { +impl RadrootsEventTagBuilder for RadrootsOperationalListing { type Error = EventEncodeError; fn build_tags(&self) -> Result<Vec<Vec<String>>, Self::Error> { - listing_tags(self) + operational_listing_tags(self) } } diff --git a/crates/event_codec/src/trade/mod.rs b/crates/event_codec/src/trade/mod.rs @@ -215,8 +215,8 @@ mod tests { use radroots_event::{ RadrootsEventEnvelope, RadrootsEventEnvelopeParts, ids::{ - RadrootsAddressableCoordinate, RadrootsDTag, RadrootsEventId, RadrootsInventoryBinId, - RadrootsPublicKey, RadrootsTradeId, + RadrootsClassifiedListingAddress, RadrootsDTag, RadrootsEventId, + RadrootsInventoryBinId, RadrootsPublicKey, RadrootsTradeId, }, trade::{ RADROOTS_TRADE_PROPOSAL_CONTRACT_ID, RADROOTS_TRADE_SCHEMA_VERSION, @@ -274,7 +274,7 @@ mod tests { farm_id: RadrootsDTag::parse("farm-1").unwrap(), lines: vec![RadrootsTradeCandidateLineV1 { line_id: RadrootsDTag::parse("line-1").unwrap(), - listing_addr: RadrootsAddressableCoordinate::parse(format!( + listing_addr: RadrootsClassifiedListingAddress::parse(format!( "30402:{}:listing-1", hex_64('b') )) diff --git a/crates/event_codec/tests/domain_encode_non_serde.rs b/crates/event_codec/tests/domain_encode_non_serde.rs @@ -13,9 +13,10 @@ use radroots_event::{ farm::{RadrootsFarm, RadrootsFarmPublicLocation, RadrootsFarmRef}, gcs::{RadrootsGcsLocation, RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon}, ids::{RadrootsDTag, RadrootsInventoryBinId}, - listing::{ - RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, - RadrootsListingDeliveryMethod, RadrootsListingProduct, RadrootsListingPublicLocation, + operational_listing::{ + RadrootsOperationalListing, RadrootsOperationalListingAvailability, + RadrootsOperationalListingBin, RadrootsOperationalListingDeliveryMethod, + RadrootsOperationalListingProduct, RadrootsOperationalListingPublicLocation, }, plot::{RadrootsPlot, RadrootsPlotLocation, RadrootsPlotRef}, resource_area::{RadrootsResourceArea, RadrootsResourceAreaLocation, RadrootsResourceAreaRef}, @@ -26,10 +27,13 @@ use radroots_event_codec::coop::list_sets::{coop_members_farms_list_set, coop_me use radroots_event_codec::document::encode::document_build_tags; use radroots_event_codec::error::EventEncodeError; use radroots_event_codec::farm::encode::{farm_build_tags, farm_ref_tags}; -use radroots_event_codec::farm::list_sets::{farm_listings_list_set, farm_members_list_set}; -use radroots_event_codec::listing::encode::listing_build_tags; -use radroots_event_codec::listing::tags::{ - ListingTagOptions, listing_tags_full, listing_tags_with_options, +use radroots_event_codec::farm::list_sets::{ + farm_members_list_set, farm_operational_listings_list_set, +}; +use radroots_event_codec::operational_listing::encode::operational_listing_build_tags; +use radroots_event_codec::operational_listing::tags::{ + OperationalListingTagOptions, operational_listing_tags_full, + operational_listing_tags_with_options, }; use radroots_event_codec::plot::encode::{plot_address, plot_build_tags}; use radroots_event_codec::resource_area::encode::{ @@ -155,7 +159,7 @@ fn sample_plot() -> RadrootsPlot { } } -fn sample_listing() -> RadrootsListing { +fn sample_listing() -> RadrootsOperationalListing { let quantity = RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::Each); let price_per_canonical_unit = RadrootsCoreQuantityPrice::new( @@ -163,14 +167,14 @@ fn sample_listing() -> RadrootsListing { quantity.clone(), ); - RadrootsListing { + RadrootsOperationalListing { d_tag: listing_d_tag(VALID_DOC_D_TAG), published_at: None, farm: RadrootsFarmRef { pubkey: VALID_PUBKEY.to_string(), d_tag: VALID_FARM_D_TAG.to_string(), }, - product: RadrootsListingProduct { + product: RadrootsOperationalListingProduct { key: "nutmeg".to_string(), title: "Nutmeg".to_string(), category: "spice".to_string(), @@ -182,7 +186,7 @@ fn sample_listing() -> RadrootsListing { year: None, }, primary_bin_id: bin_id("bin-1"), - bins: vec![RadrootsListingBin { + bins: vec![RadrootsOperationalListingBin { bin_id: bin_id("bin-1"), quantity, price_per_canonical_unit, @@ -196,11 +200,11 @@ fn sample_listing() -> RadrootsListing { plot: None, discounts: None, inventory_available: Some(decimal("12")), - availability: Some(RadrootsListingAvailability::Window { + availability: Some(RadrootsOperationalListingAvailability::Window { start: Some(1), end: Some(2), }), - delivery_method: Some(RadrootsListingDeliveryMethod::Shipping), + delivery_method: Some(RadrootsOperationalListingDeliveryMethod::Shipping), location: None, images: None, } @@ -528,14 +532,14 @@ fn farm_encode_and_list_set_paths() { EventEncodeError::EmptyRequiredField("entry.values") )); - let err = farm_listings_list_set(VALID_FARM_D_TAG, VALID_PUBKEY, [" "]) + let err = farm_operational_listings_list_set(VALID_FARM_D_TAG, VALID_PUBKEY, [" "]) .expect_err("empty listing id"); assert!(matches!( err, EventEncodeError::EmptyRequiredField("listing_id") )); - let err = farm_listings_list_set(VALID_FARM_D_TAG, VALID_PUBKEY, ["invalid"]) + let err = farm_operational_listings_list_set(VALID_FARM_D_TAG, VALID_PUBKEY, ["invalid"]) .expect_err("invalid listing id"); assert!(matches!(err, EventEncodeError::InvalidField("listing_id"))); } @@ -633,13 +637,13 @@ fn plot_encode_paths() { #[test] fn listing_encode_paths() { let listing = sample_listing(); - let tags = listing_build_tags(&listing).expect("listing tags"); + let tags = operational_listing_build_tags(&listing).expect("listing tags"); assert!( tags.iter() .any(|tag| tag.first().map(|v| v.as_str()) == Some("d")) ); - let full_tags = listing_tags_full(&listing).expect("listing full tags"); + let full_tags = operational_listing_tags_full(&listing).expect("listing full tags"); assert!(full_tags.iter().any(|tag| { tag.first().map(|v| v.as_str()) == Some("inventory") && tag.get(1).map(|v| v.as_str()) == Some("12") @@ -657,9 +661,10 @@ fn listing_encode_paths() { && tag.get(1).map(|v| v.as_str()) == Some("shipping") })); - let with_trade_fields: fn() -> ListingTagOptions = ListingTagOptions::with_trade_fields; - let option_tags = - listing_tags_with_options(&listing, with_trade_fields()).expect("listing option tags"); + let with_trade_fields: fn() -> OperationalListingTagOptions = + OperationalListingTagOptions::with_trade_fields; + let option_tags = operational_listing_tags_with_options(&listing, with_trade_fields()) + .expect("listing option tags"); assert!(option_tags.iter().any(|tag| { tag.first().map(|v| v.as_str()) == Some("inventory") && tag.get(1).map(|v| v.as_str()) == Some("12") @@ -673,27 +678,29 @@ fn listing_encode_paths() { listing_with_display_fallback.bins[0].display_amount = Some(decimal("1")); listing_with_display_fallback.bins[0].display_unit = Some(RadrootsCoreUnit::Each); listing_with_display_fallback.bins[0].display_label = None; - let display_tags = - listing_tags_with_options(&listing_with_display_fallback, ListingTagOptions::default()) - .expect("listing tags with display fallback"); + let display_tags = operational_listing_tags_with_options( + &listing_with_display_fallback, + OperationalListingTagOptions::default(), + ) + .expect("listing tags with display fallback"); assert!(display_tags.iter().any(|tag| { tag.first().map(|v| v.as_str()) == Some("radroots:bin") && tag.last().map(|v| v.as_str()) == Some("fallback-label") })); let mut listing_with_geohash = sample_listing(); - listing_with_geohash.location = Some(RadrootsListingPublicLocation { + listing_with_geohash.location = Some(RadrootsOperationalListingPublicLocation { primary: "Origin".to_string(), city: Some("Town".to_string()), region: Some("Region".to_string()), country: Some("PE".to_string()), geohash: "6gkzw".to_string(), }); - let decoded_tags = listing_tags_with_options( + let decoded_tags = operational_listing_tags_with_options( &listing_with_geohash, - ListingTagOptions { + OperationalListingTagOptions { include_geohash: false, - ..ListingTagOptions::default() + ..OperationalListingTagOptions::default() }, ) .expect("listing tags without geohash tag"); @@ -704,32 +711,36 @@ fn listing_encode_paths() { ); let mut listing_with_shared_geohash = sample_listing(); - listing_with_shared_geohash.location = Some(RadrootsListingPublicLocation { + listing_with_shared_geohash.location = Some(RadrootsOperationalListingPublicLocation { primary: "Origin".to_string(), city: Some("Town".to_string()), region: Some("Region".to_string()), country: Some("PE".to_string()), geohash: "6gkzw".to_string(), }); - let shared_geohash_tags = - listing_tags_with_options(&listing_with_shared_geohash, ListingTagOptions::default()) - .expect("listing tags with shared geohash"); + let shared_geohash_tags = operational_listing_tags_with_options( + &listing_with_shared_geohash, + OperationalListingTagOptions::default(), + ) + .expect("listing tags with shared geohash"); assert!(shared_geohash_tags.iter().any(|tag| { tag.first().map(|v| v.as_str()) == Some("g") && tag.get(1).map(|v| v.as_str()) == Some("6gkzw") })); let mut listing_without_coordinates = sample_listing(); - listing_without_coordinates.location = Some(RadrootsListingPublicLocation { + listing_without_coordinates.location = Some(RadrootsOperationalListingPublicLocation { primary: "Origin".to_string(), city: Some("Town".to_string()), region: Some("Region".to_string()), country: Some("PE".to_string()), geohash: "6gkzw".to_string(), }); - let no_coordinates_tags = - listing_tags_with_options(&listing_without_coordinates, ListingTagOptions::default()) - .expect("listing tags without coordinates"); + let no_coordinates_tags = operational_listing_tags_with_options( + &listing_without_coordinates, + OperationalListingTagOptions::default(), + ) + .expect("listing tags without coordinates"); assert!( !no_coordinates_tags .iter() @@ -746,16 +757,18 @@ fn listing_encode_paths() { listing_with_blank_optionals.product.location = Some(" ".to_string()); listing_with_blank_optionals.product.profile = Some("null".to_string()); listing_with_blank_optionals.product.year = Some(" ".to_string()); - listing_with_blank_optionals.location = Some(RadrootsListingPublicLocation { + listing_with_blank_optionals.location = Some(RadrootsOperationalListingPublicLocation { primary: " ".to_string(), city: Some(" ".to_string()), region: Some("null".to_string()), country: Some(" ".to_string()), geohash: "6gkzw".to_string(), }); - let blank_optional_tags = - listing_tags_with_options(&listing_with_blank_optionals, ListingTagOptions::default()) - .expect_err("blank public location is invalid"); + let blank_optional_tags = operational_listing_tags_with_options( + &listing_with_blank_optionals, + OperationalListingTagOptions::default(), + ) + .expect_err("blank public location is invalid"); assert!(matches!( blank_optional_tags, EventEncodeError::EmptyRequiredField("location.primary") @@ -766,9 +779,9 @@ fn listing_encode_paths() { listing_with_blank_product_optionals.product.location = Some(" ".to_string()); listing_with_blank_product_optionals.product.profile = Some("null".to_string()); listing_with_blank_product_optionals.product.year = Some(" ".to_string()); - let blank_optional_tags = listing_tags_with_options( + let blank_optional_tags = operational_listing_tags_with_options( &listing_with_blank_product_optionals, - ListingTagOptions::default(), + OperationalListingTagOptions::default(), ) .expect("listing tags with blank product optional values"); assert!( @@ -783,18 +796,18 @@ fn listing_encode_paths() { ); let mut listing_without_geohash = sample_listing(); - listing_without_geohash.location = Some(RadrootsListingPublicLocation { + listing_without_geohash.location = Some(RadrootsOperationalListingPublicLocation { primary: "Origin".to_string(), city: Some("Town".to_string()), region: Some("Region".to_string()), country: Some("PE".to_string()), geohash: "6gkzw".to_string(), }); - let without_geohash_tags = listing_tags_with_options( + let without_geohash_tags = operational_listing_tags_with_options( &listing_without_geohash, - ListingTagOptions { + OperationalListingTagOptions { include_geohash: false, - ..ListingTagOptions::default() + ..OperationalListingTagOptions::default() }, ) .expect("listing tags without geohash"); @@ -807,7 +820,7 @@ fn listing_encode_paths() { let mut listing_without_availability = sample_listing(); listing_without_availability.availability = None; let no_availability_tags = - listing_tags_with_options(&listing_without_availability, with_trade_fields()) + operational_listing_tags_with_options(&listing_without_availability, with_trade_fields()) .expect("listing tags without availability"); assert!( !no_availability_tags @@ -816,8 +829,8 @@ fn listing_encode_paths() { ); let mut listing_pickup = sample_listing(); - listing_pickup.delivery_method = Some(RadrootsListingDeliveryMethod::Pickup); - let pickup_tags = listing_tags_with_options(&listing_pickup, with_trade_fields()) + listing_pickup.delivery_method = Some(RadrootsOperationalListingDeliveryMethod::Pickup); + let pickup_tags = operational_listing_tags_with_options(&listing_pickup, with_trade_fields()) .expect("listing tags with pickup delivery"); assert!(pickup_tags.iter().any(|tag| { tag.first().map(|v| v.as_str()) == Some("delivery") @@ -825,8 +838,8 @@ fn listing_encode_paths() { })); let mut listing_local = sample_listing(); - listing_local.delivery_method = Some(RadrootsListingDeliveryMethod::LocalDelivery); - let local_tags = listing_tags_with_options(&listing_local, with_trade_fields()) + listing_local.delivery_method = Some(RadrootsOperationalListingDeliveryMethod::LocalDelivery); + let local_tags = operational_listing_tags_with_options(&listing_local, with_trade_fields()) .expect("listing tags with local delivery"); assert!(local_tags.iter().any(|tag| { tag.first().map(|v| v.as_str()) == Some("delivery") @@ -834,11 +847,12 @@ fn listing_encode_paths() { })); let mut listing_other_delivery = sample_listing(); - listing_other_delivery.delivery_method = Some(RadrootsListingDeliveryMethod::Other { - method: "courier".to_string(), - }); + listing_other_delivery.delivery_method = + Some(RadrootsOperationalListingDeliveryMethod::Other { + method: "courier".to_string(), + }); let other_delivery_tags = - listing_tags_with_options(&listing_other_delivery, with_trade_fields()) + operational_listing_tags_with_options(&listing_other_delivery, with_trade_fields()) .expect("listing tags with other delivery"); assert!(other_delivery_tags.iter().any(|tag| { tag.first().map(|v| v.as_str()) == Some("delivery") @@ -849,8 +863,9 @@ fn listing_encode_paths() { let mut invalid = sample_listing(); invalid.bins[0].display_price = None; invalid.bins[0].display_price_unit = Some(RadrootsCoreUnit::Each); - let err = listing_tags_with_options(&invalid, ListingTagOptions::default()) - .expect_err("missing display price"); + let err = + operational_listing_tags_with_options(&invalid, OperationalListingTagOptions::default()) + .expect_err("missing display price"); assert!(matches!( err, EventEncodeError::EmptyRequiredField("bin.display_price") @@ -862,8 +877,9 @@ fn listing_encode_paths() { RadrootsCoreCurrency::USD, )); invalid.bins[0].display_price_unit = None; - let err = listing_tags_with_options(&invalid, ListingTagOptions::default()) - .expect_err("missing display price unit"); + let err = + operational_listing_tags_with_options(&invalid, OperationalListingTagOptions::default()) + .expect_err("missing display price unit"); assert!(matches!( err, EventEncodeError::EmptyRequiredField("bin.display_price_unit") @@ -875,9 +891,11 @@ fn listing_encode_paths() { RadrootsCoreCurrency::USD, )); listing_with_display_price.bins[0].display_price_unit = Some(RadrootsCoreUnit::Each); - let display_price_tags = - listing_tags_with_options(&listing_with_display_price, ListingTagOptions::default()) - .expect("listing tags with display price"); + let display_price_tags = operational_listing_tags_with_options( + &listing_with_display_price, + OperationalListingTagOptions::default(), + ) + .expect("listing tags with display price"); assert!(display_price_tags.iter().any(|tag| { tag.first().map(|v| v.as_str()) == Some("radroots:price") && tag.get(6).map(|v| v.as_str()) == Some("10") @@ -889,8 +907,9 @@ fn listing_encode_paths() { decimal("10"), RadrootsCoreCurrency::EUR, )); - let err = listing_tags_with_options(&invalid, ListingTagOptions::default()) - .expect_err("display price currency mismatch"); + let err = + operational_listing_tags_with_options(&invalid, OperationalListingTagOptions::default()) + .expect_err("display price currency mismatch"); assert!(matches!( err, EventEncodeError::EmptyRequiredField("bin.display_price") @@ -899,8 +918,9 @@ fn listing_encode_paths() { let mut invalid = sample_listing(); invalid.bins[0].display_amount = None; invalid.bins[0].display_unit = Some(RadrootsCoreUnit::Each); - let err = listing_tags_with_options(&invalid, ListingTagOptions::default()) - .expect_err("missing display amount"); + let err = + operational_listing_tags_with_options(&invalid, OperationalListingTagOptions::default()) + .expect_err("missing display amount"); assert!(matches!( err, EventEncodeError::EmptyRequiredField("bin.display_amount") @@ -912,8 +932,9 @@ fn listing_encode_paths() { RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD), RadrootsCoreQuantity::new(decimal("1"), RadrootsCoreUnit::MassG), ); - let err = listing_tags_with_options(&invalid, ListingTagOptions::default()) - .expect_err("non-canonical bin quantity"); + let err = + operational_listing_tags_with_options(&invalid, OperationalListingTagOptions::default()) + .expect_err("non-canonical bin quantity"); assert!(matches!( err, EventEncodeError::EmptyRequiredField("bin.quantity") @@ -924,8 +945,9 @@ fn listing_encode_paths() { RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD), RadrootsCoreQuantity::new(decimal("2"), RadrootsCoreUnit::Each), ); - let err = listing_tags_with_options(&invalid, ListingTagOptions::default()) - .expect_err("price must be per canonical unit"); + let err = + operational_listing_tags_with_options(&invalid, OperationalListingTagOptions::default()) + .expect_err("price must be per canonical unit"); assert!(matches!( err, EventEncodeError::EmptyRequiredField("bin.price_per_canonical_unit") @@ -936,8 +958,9 @@ fn listing_encode_paths() { RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD), RadrootsCoreQuantity::new(decimal("1"), RadrootsCoreUnit::MassG), ); - let err = listing_tags_with_options(&invalid, ListingTagOptions::default()) - .expect_err("non-convertible bin total price"); + let err = + operational_listing_tags_with_options(&invalid, OperationalListingTagOptions::default()) + .expect_err("non-convertible bin total price"); assert!(matches!( err, EventEncodeError::EmptyRequiredField("bin.price_per_canonical_unit") @@ -945,7 +968,7 @@ fn listing_encode_paths() { let mut invalid = sample_listing(); invalid.farm.d_tag = " ".to_string(); - let err = listing_build_tags(&invalid).expect_err("empty listing farm d_tag"); + let err = operational_listing_build_tags(&invalid).expect_err("empty listing farm d_tag"); assert!(matches!( err, EventEncodeError::EmptyRequiredField("farm.d_tag") @@ -954,7 +977,8 @@ fn listing_encode_paths() { let mut invalid = sample_listing(); invalid.bins.clear(); let err = - listing_tags_with_options(&invalid, ListingTagOptions::default()).expect_err("empty bins"); + operational_listing_tags_with_options(&invalid, OperationalListingTagOptions::default()) + .expect_err("empty bins"); assert!(matches!(err, EventEncodeError::EmptyRequiredField("bins"))); } diff --git a/crates/event_codec/tests/fixtures/listing_parse_event.v1.json b/crates/event_codec/tests/fixtures/listing_parse_event.v1.json @@ -1,215 +0,0 @@ -{ - "suite": "operational_listing_parse_event", - "contract_version": "1.0.0", - "vectors": [ - { - "id": "operational_listing_tags_override_json_content_001", - "kind": "listing.parse_event.valid", - "input": { - "event_kind": 30402, - "content": "{\"product\":{\"title\":\"Content override\",\"category\":\"Content category\"},\"inventory_available\":\"99\"}", - "tags": [ - ["d", "AAAAAAAAAAAAAAAAAAAAAg"], - ["p", "farm_pubkey"], - ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], - ["key", "sku"], - ["title", "Widget"], - ["category", "Tools"], - ["radroots:primary_bin", "bin-1"], - ["radroots:bin", "bin-1", "1", "each"], - ["radroots:price", "bin-1", "10", "USD", "1", "each"], - ["price", "10", "USD"] - ] - }, - "expected": { - "listing": { - "d_tag": "AAAAAAAAAAAAAAAAAAAAAg", - "title": "Widget", - "category": "Tools", - "primary_bin_id": "bin-1", - "quantity_amount": "1", - "quantity_unit": "each", - "price_amount": "10", - "price_currency": "USD", - "inventory_available": null - } - } - }, - { - "id": "operational_listing_missing_d_001", - "kind": "listing.parse_event.invalid", - "input": { - "event_kind": 30402, - "content": "# Widget", - "tags": [ - ["p", "farm_pubkey"], - ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], - ["key", "sku"], - ["title", "Widget"], - ["category", "Tools"], - ["radroots:primary_bin", "bin-1"], - ["radroots:bin", "bin-1", "1", "each"], - ["radroots:price", "bin-1", "10", "USD", "1", "each"] - ] - }, - "expected": { - "error": {"MissingTag": "d"} - } - }, - { - "id": "operational_listing_invalid_d_001", - "kind": "listing.parse_event.invalid", - "input": { - "event_kind": 30402, - "content": "# Widget", - "tags": [ - ["d", "bad d"], - ["p", "farm_pubkey"], - ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], - ["key", "sku"], - ["title", "Widget"], - ["category", "Tools"], - ["radroots:primary_bin", "bin-1"], - ["radroots:bin", "bin-1", "1", "each"], - ["radroots:price", "bin-1", "10", "USD", "1", "each"] - ] - }, - "expected": { - "error": {"InvalidTag": "d"} - } - }, - { - "id": "operational_listing_invalid_number_001", - "kind": "listing.parse_event.invalid", - "input": { - "event_kind": 30402, - "content": "# Widget", - "tags": [ - ["d", "AAAAAAAAAAAAAAAAAAAAAg"], - ["p", "farm_pubkey"], - ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], - ["key", "sku"], - ["title", "Widget"], - ["category", "Tools"], - ["radroots:primary_bin", "bin-1"], - ["radroots:bin", "bin-1", "bad", "each"], - ["radroots:price", "bin-1", "10", "USD", "1", "each"] - ] - }, - "expected": { - "error": {"InvalidNumber": "radroots:bin"} - } - }, - { - "id": "operational_listing_invalid_unit_001", - "kind": "listing.parse_event.invalid", - "input": { - "event_kind": 30402, - "content": "# Widget", - "tags": [ - ["d", "AAAAAAAAAAAAAAAAAAAAAg"], - ["p", "farm_pubkey"], - ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], - ["key", "sku"], - ["title", "Widget"], - ["category", "Tools"], - ["radroots:primary_bin", "bin-1"], - ["radroots:bin", "bin-1", "1", "bad"], - ["radroots:price", "bin-1", "10", "USD", "1", "each"] - ] - }, - "expected": { - "error": "InvalidUnit" - } - }, - { - "id": "operational_listing_invalid_currency_001", - "kind": "listing.parse_event.invalid", - "input": { - "event_kind": 30402, - "content": "# Widget", - "tags": [ - ["d", "AAAAAAAAAAAAAAAAAAAAAg"], - ["p", "farm_pubkey"], - ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], - ["key", "sku"], - ["title", "Widget"], - ["category", "Tools"], - ["radroots:primary_bin", "bin-1"], - ["radroots:bin", "bin-1", "1", "each"], - ["radroots:price", "bin-1", "10", "US", "1", "each"] - ] - }, - "expected": { - "error": "InvalidCurrency" - } - }, - { - "id": "operational_listing_invalid_discount_001", - "kind": "listing.parse_event.invalid", - "input": { - "event_kind": 30402, - "content": "# Widget", - "tags": [ - ["d", "AAAAAAAAAAAAAAAAAAAAAg"], - ["p", "farm_pubkey"], - ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], - ["key", "sku"], - ["title", "Widget"], - ["category", "Tools"], - ["radroots:primary_bin", "bin-1"], - ["radroots:bin", "bin-1", "1", "each"], - ["radroots:price", "bin-1", "10", "USD", "1", "each"], - ["radroots:discount", "{"] - ] - }, - "expected": { - "error": {"InvalidDiscount": "radroots:discount"} - } - }, - { - "id": "operational_listing_private_json_location_001", - "kind": "listing.parse_event.invalid", - "input": { - "event_kind": 30402, - "content": "{\"location\":{\"coordinates\":[1,2]}}", - "tags": [ - ["d", "AAAAAAAAAAAAAAAAAAAAAg"], - ["p", "farm_pubkey"], - ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], - ["key", "sku"], - ["title", "Widget"], - ["category", "Tools"], - ["radroots:primary_bin", "bin-1"], - ["radroots:bin", "bin-1", "1", "each"], - ["radroots:price", "bin-1", "10", "USD", "1", "each"] - ] - }, - "expected": { - "error": {"InvalidJson": "location"} - } - }, - { - "id": "operational_listing_invalid_kind_001", - "kind": "listing.parse_event.invalid", - "input": { - "event_kind": 0, - "content": "# Widget", - "tags": [ - ["d", "AAAAAAAAAAAAAAAAAAAAAg"], - ["p", "farm_pubkey"], - ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], - ["key", "sku"], - ["title", "Widget"], - ["category", "Tools"], - ["radroots:primary_bin", "bin-1"], - ["radroots:bin", "bin-1", "1", "each"], - ["radroots:price", "bin-1", "10", "USD", "1", "each"] - ] - }, - "expected": { - "error": {"InvalidKind": 0} - } - } - ] -} diff --git a/crates/event_codec/tests/fixtures/operational_listing_parse_event.v1.json b/crates/event_codec/tests/fixtures/operational_listing_parse_event.v1.json @@ -0,0 +1,215 @@ +{ + "suite": "operational_listing", + "contract_version": "1.0.0", + "vectors": [ + { + "id": "operational_listing_tags_override_json_content_001", + "kind": "operational_listing.parse_event.valid", + "input": { + "event_kind": 30402, + "content": "{\"product\":{\"title\":\"Content override\",\"category\":\"Content category\"},\"inventory_available\":\"99\"}", + "tags": [ + ["d", "AAAAAAAAAAAAAAAAAAAAAg"], + ["p", "farm_pubkey"], + ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "sku"], + ["title", "Widget"], + ["category", "Tools"], + ["radroots:primary_bin", "bin-1"], + ["radroots:bin", "bin-1", "1", "each"], + ["radroots:price", "bin-1", "10", "USD", "1", "each"], + ["price", "10", "USD"] + ] + }, + "expected": { + "listing": { + "d_tag": "AAAAAAAAAAAAAAAAAAAAAg", + "title": "Widget", + "category": "Tools", + "primary_bin_id": "bin-1", + "quantity_amount": "1", + "quantity_unit": "each", + "price_amount": "10", + "price_currency": "USD", + "inventory_available": null + } + } + }, + { + "id": "operational_listing_missing_d_001", + "kind": "operational_listing.parse_event.invalid", + "input": { + "event_kind": 30402, + "content": "# Widget", + "tags": [ + ["p", "farm_pubkey"], + ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "sku"], + ["title", "Widget"], + ["category", "Tools"], + ["radroots:primary_bin", "bin-1"], + ["radroots:bin", "bin-1", "1", "each"], + ["radroots:price", "bin-1", "10", "USD", "1", "each"] + ] + }, + "expected": { + "error": {"MissingTag": "d"} + } + }, + { + "id": "operational_listing_invalid_d_001", + "kind": "operational_listing.parse_event.invalid", + "input": { + "event_kind": 30402, + "content": "# Widget", + "tags": [ + ["d", "bad d"], + ["p", "farm_pubkey"], + ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "sku"], + ["title", "Widget"], + ["category", "Tools"], + ["radroots:primary_bin", "bin-1"], + ["radroots:bin", "bin-1", "1", "each"], + ["radroots:price", "bin-1", "10", "USD", "1", "each"] + ] + }, + "expected": { + "error": {"InvalidTag": "d"} + } + }, + { + "id": "operational_listing_invalid_number_001", + "kind": "operational_listing.parse_event.invalid", + "input": { + "event_kind": 30402, + "content": "# Widget", + "tags": [ + ["d", "AAAAAAAAAAAAAAAAAAAAAg"], + ["p", "farm_pubkey"], + ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "sku"], + ["title", "Widget"], + ["category", "Tools"], + ["radroots:primary_bin", "bin-1"], + ["radroots:bin", "bin-1", "bad", "each"], + ["radroots:price", "bin-1", "10", "USD", "1", "each"] + ] + }, + "expected": { + "error": {"InvalidNumber": "radroots:bin"} + } + }, + { + "id": "operational_listing_invalid_unit_001", + "kind": "operational_listing.parse_event.invalid", + "input": { + "event_kind": 30402, + "content": "# Widget", + "tags": [ + ["d", "AAAAAAAAAAAAAAAAAAAAAg"], + ["p", "farm_pubkey"], + ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "sku"], + ["title", "Widget"], + ["category", "Tools"], + ["radroots:primary_bin", "bin-1"], + ["radroots:bin", "bin-1", "1", "bad"], + ["radroots:price", "bin-1", "10", "USD", "1", "each"] + ] + }, + "expected": { + "error": "InvalidUnit" + } + }, + { + "id": "operational_listing_invalid_currency_001", + "kind": "operational_listing.parse_event.invalid", + "input": { + "event_kind": 30402, + "content": "# Widget", + "tags": [ + ["d", "AAAAAAAAAAAAAAAAAAAAAg"], + ["p", "farm_pubkey"], + ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "sku"], + ["title", "Widget"], + ["category", "Tools"], + ["radroots:primary_bin", "bin-1"], + ["radroots:bin", "bin-1", "1", "each"], + ["radroots:price", "bin-1", "10", "US", "1", "each"] + ] + }, + "expected": { + "error": "InvalidCurrency" + } + }, + { + "id": "operational_listing_invalid_discount_001", + "kind": "operational_listing.parse_event.invalid", + "input": { + "event_kind": 30402, + "content": "# Widget", + "tags": [ + ["d", "AAAAAAAAAAAAAAAAAAAAAg"], + ["p", "farm_pubkey"], + ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "sku"], + ["title", "Widget"], + ["category", "Tools"], + ["radroots:primary_bin", "bin-1"], + ["radroots:bin", "bin-1", "1", "each"], + ["radroots:price", "bin-1", "10", "USD", "1", "each"], + ["radroots:discount", "{"] + ] + }, + "expected": { + "error": {"InvalidDiscount": "radroots:discount"} + } + }, + { + "id": "operational_listing_private_json_location_001", + "kind": "operational_listing.parse_event.invalid", + "input": { + "event_kind": 30402, + "content": "{\"location\":{\"coordinates\":[1,2]}}", + "tags": [ + ["d", "AAAAAAAAAAAAAAAAAAAAAg"], + ["p", "farm_pubkey"], + ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "sku"], + ["title", "Widget"], + ["category", "Tools"], + ["radroots:primary_bin", "bin-1"], + ["radroots:bin", "bin-1", "1", "each"], + ["radroots:price", "bin-1", "10", "USD", "1", "each"] + ] + }, + "expected": { + "error": {"InvalidJson": "location"} + } + }, + { + "id": "operational_listing_invalid_kind_001", + "kind": "operational_listing.parse_event.invalid", + "input": { + "event_kind": 0, + "content": "# Widget", + "tags": [ + ["d", "AAAAAAAAAAAAAAAAAAAAAg"], + ["p", "farm_pubkey"], + ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "sku"], + ["title", "Widget"], + ["category", "Tools"], + ["radroots:primary_bin", "bin-1"], + ["radroots:bin", "bin-1", "1", "each"], + ["radroots:price", "bin-1", "10", "USD", "1", "each"] + ] + }, + "expected": { + "error": {"InvalidKind": 0} + } + } + ] +} diff --git a/crates/event_codec/tests/listing.rs b/crates/event_codec/tests/listing.rs @@ -1,1524 +0,0 @@ -#![cfg(feature = "serde_json")] - -use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreDiscountScope, - RadrootsCoreDiscountThreshold, RadrootsCoreDiscountValue, RadrootsCoreMoney, - RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit, -}; -use radroots_event::{ - RadrootsEventEnvelope, RadrootsEventEnvelopeParts, - farm::RadrootsFarmRef, - ids::{RadrootsDTag, RadrootsInventoryBinId}, - kinds::{KIND_FARM, KIND_LISTING, KIND_PLOT, KIND_POST, KIND_RESOURCE_AREA}, - listing::{ - RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, - RadrootsListingDeliveryMethod, RadrootsListingImage, RadrootsListingImageSize, - RadrootsListingProduct, RadrootsListingPublicLocation, RadrootsListingStatus, - }, - order::RadrootsListingParseError, - plot::RadrootsPlotRef, - resource_area::RadrootsResourceAreaRef, - tags::{TAG_D, TAG_PUBLISHED_AT}, -}; -use radroots_event_codec::error::{EventEncodeError, EventParseError}; -use radroots_event_codec::listing::decode::{ - data_from_event, data_from_nostr_event, listing_from_event, listing_from_event_parts, - listing_from_nostr_event, parsed_from_event, parsed_from_nostr_event, -}; -use radroots_event_codec::listing::encode::{ - listing_build_tags, to_json_wire_parts_with_kind, to_wire_parts, to_wire_parts_with_kind, -}; -use radroots_event_codec::listing::tags::{ - ListingTagOptions, listing_tags_full, listing_tags_with_options, -}; -use std::{borrow::Cow, collections::BTreeSet, fs, path::Path, str::FromStr}; - -use serde_json::Value; - -const EVENT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; -const AUTHOR: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; -const EVENT_SIG: &str = concat!( - "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", - "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" -); -const PACKAGED_PARSE_VECTORS: &str = include_str!("fixtures/listing_parse_event.v1.json"); -const WORKSPACE_PARSE_VECTOR_PATH: &str = - "../../contracts/conformance/vectors/listing/parse_event.v1.json"; -const WORKSPACE_CONTRACT_MARKER_PATH: &str = "../../contracts/manifest.toml"; - -fn listing_d_tag(raw: &str) -> RadrootsDTag { - raw.parse().unwrap() -} - -fn bin_id(raw: &str) -> RadrootsInventoryBinId { - raw.parse().unwrap() -} - -fn sample_listing_tags() -> Vec<Vec<String>> { - listing_build_tags(&sample_listing("AAAAAAAAAAAAAAAAAAAAAg")).unwrap() -} - -fn remove_tags(tags: &mut Vec<Vec<String>>, name: &str) { - tags.retain(|tag| tag.first().map(|value| value.as_str()) != Some(name)); -} - -fn replace_first_tag(tags: &mut [Vec<String>], name: &str, replacement: Vec<&str>) { - let tag = tags - .iter_mut() - .find(|tag| tag.first().map(|value| value.as_str()) == Some(name)) - .expect("tag"); - *tag = replacement.into_iter().map(str::to_string).collect(); -} - -fn event_envelope(kind: u32, tags: Vec<Vec<String>>, content: String) -> RadrootsEventEnvelope { - RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { - id: EVENT_ID.to_string(), - author: AUTHOR.to_string(), - created_at: 7, - kind, - tags, - content, - sig: EVENT_SIG.to_string(), - }) - .unwrap() -} - -fn assert_missing_tag(tags: Vec<Vec<String>>, expected: &'static str) { - match listing_from_event(KIND_LISTING, &tags, "# Widget") { - Err(EventParseError::MissingTag(tag)) => assert_eq!(tag, expected), - other => panic!("expected missing tag {expected}: {other:?}"), - } -} - -fn assert_invalid_tag(tags: Vec<Vec<String>>, expected: &'static str) { - match listing_from_event(KIND_LISTING, &tags, "# Widget") { - Err(EventParseError::InvalidTag(tag)) => assert_eq!(tag, expected), - other => panic!("expected invalid tag {expected}: {other:?}"), - } -} - -fn sample_listing(d_tag: &str) -> RadrootsListing { - let quantity = - RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::Each); - let price = RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new(RadrootsCoreDecimal::from(10u32), RadrootsCoreCurrency::USD), - quantity.clone(), - ); - - RadrootsListing { - d_tag: listing_d_tag(d_tag), - published_at: None, - farm: RadrootsFarmRef { - pubkey: "farm_pubkey".to_string(), - d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), - }, - product: RadrootsListingProduct { - key: "sku".to_string(), - title: "Widget".to_string(), - category: "Tools".to_string(), - summary: None, - process: None, - lot: None, - location: None, - profile: None, - year: None, - }, - primary_bin_id: bin_id("bin-1"), - bins: vec![RadrootsListingBin { - bin_id: bin_id("bin-1"), - quantity, - price_per_canonical_unit: price, - display_amount: None, - display_unit: None, - display_label: None, - display_price: None, - display_price_unit: None, - }], - resource_area: None, - plot: None, - discounts: None, - inventory_available: None, - availability: None, - delivery_method: None, - location: None, - images: None, - } -} - -#[test] -fn listing_from_event_parts_preserves_listing_error_taxonomy() { - let mut tags = sample_listing_tags(); - remove_tags(&mut tags, TAG_D); - assert_eq!( - listing_from_event_parts(&tags, "").unwrap_err(), - RadrootsListingParseError::MissingTag(TAG_D.to_string()) - ); - - let mut tags = sample_listing_tags(); - replace_first_tag(&mut tags, TAG_D, vec![TAG_D, "bad d"]); - assert_eq!( - listing_from_event_parts(&tags, "").unwrap_err(), - RadrootsListingParseError::InvalidTag(TAG_D.to_string()) - ); - - let mut tags = sample_listing_tags(); - replace_first_tag( - &mut tags, - "radroots:bin", - vec!["radroots:bin", "bin-1", "bad", "each"], - ); - assert_eq!( - listing_from_event_parts(&tags, "").unwrap_err(), - RadrootsListingParseError::InvalidNumber("radroots:bin".to_string()) - ); - - let mut tags = sample_listing_tags(); - replace_first_tag( - &mut tags, - "radroots:bin", - vec!["radroots:bin", "bin-1", "1", "bad"], - ); - assert_eq!( - listing_from_event_parts(&tags, "").unwrap_err(), - RadrootsListingParseError::InvalidUnit - ); - - let mut tags = sample_listing_tags(); - replace_first_tag( - &mut tags, - "radroots:price", - vec!["radroots:price", "bin-1", "10", "not-currency", "1", "each"], - ); - assert_eq!( - listing_from_event_parts(&tags, "").unwrap_err(), - RadrootsListingParseError::InvalidCurrency - ); - - let mut tags = sample_listing_tags(); - tags.push(vec!["radroots:discount".to_string(), "{".to_string()]); - assert_eq!( - listing_from_event_parts(&tags, "").unwrap_err(), - RadrootsListingParseError::InvalidDiscount("radroots:discount".to_string()) - ); - - assert_eq!( - listing_from_event_parts( - &sample_listing_tags(), - r#"{"location":{"coordinates":[1,2]}}"#, - ) - .unwrap_err(), - RadrootsListingParseError::InvalidJson("location".to_string()) - ); -} - -#[test] -fn listing_from_event_parts_does_not_allow_json_content_to_override_tags() { - let tags = sample_listing_tags(); - let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg"); - listing.product.title = "Content override".to_string(); - listing.product.category = "Content category".to_string(); - listing.inventory_available = Some(RadrootsCoreDecimal::from(99u32)); - listing.bins[0].quantity.amount = RadrootsCoreDecimal::from(42u32); - - let decoded = listing_from_event_parts(&tags, &serde_json::to_string(&listing).unwrap()) - .expect("decode tag-authoritative listing"); - - assert_eq!(decoded.product.title, "Widget"); - assert_eq!(decoded.product.category, "Tools"); - assert_eq!(decoded.inventory_available, None); - assert_eq!( - decoded.bins[0].quantity.amount, - RadrootsCoreDecimal::from(1u32) - ); -} - -#[test] -fn checked_in_listing_parse_vectors_execute_against_the_typed_decoder() { - let vectors = listing_parse_vectors(); - let suite: Value = serde_json::from_str(&vectors).expect("listing parse vectors must parse"); - assert_eq!(suite["suite"], "operational_listing_parse_event"); - assert_eq!(suite["contract_version"], "1.0.0"); - let vectors = suite["vectors"].as_array().expect("listing parse vectors"); - assert_eq!(vectors.len(), 9, "listing parse vector inventory drifted"); - - let mut ids = BTreeSet::new(); - for vector in vectors { - let id = vector["id"].as_str().expect("vector id"); - assert!(ids.insert(id), "duplicate listing parse vector id {id}"); - execute_listing_parse_vector(vector, id); - } -} - -fn listing_parse_vectors() -> Cow<'static, str> { - let workspace_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(WORKSPACE_PARSE_VECTOR_PATH); - match fs::read_to_string(&workspace_path) { - Ok(canonical) => { - assert_eq!( - canonical, - PACKAGED_PARSE_VECTORS, - "packaged listing parse vectors must match {}", - workspace_path.display() - ); - Cow::Owned(canonical) - } - Err(error) - if error.kind() == std::io::ErrorKind::NotFound - && !Path::new(env!("CARGO_MANIFEST_DIR")) - .join(WORKSPACE_CONTRACT_MARKER_PATH) - .is_file() => - { - Cow::Borrowed(PACKAGED_PARSE_VECTORS) - } - Err(error) => panic!("failed to read {}: {error}", workspace_path.display()), - } -} - -fn execute_listing_parse_vector(vector: &Value, id: &str) { - let input = &vector["input"]; - let tags: Vec<Vec<String>> = - serde_json::from_value(input["tags"].clone()).expect("vector tags"); - let content = input["content"].as_str().expect("vector content"); - let event_kind = input["event_kind"].as_u64().expect("vector event kind") as u32; - let event = event_envelope(event_kind, tags, content.to_string()); - - match vector["kind"].as_str().expect("vector kind") { - "listing.parse_event.valid" => { - let listing = listing_from_nostr_event(&event) - .unwrap_or_else(|error| panic!("{id} failed: {error}")); - let expected = &vector["expected"]["listing"]; - assert_eq!(listing.d_tag.as_str(), expected["d_tag"], "{id}"); - assert_eq!(listing.product.title, expected["title"], "{id}"); - assert_eq!(listing.product.category, expected["category"], "{id}"); - assert_eq!( - listing.primary_bin_id.as_str(), - expected["primary_bin_id"], - "{id}" - ); - let primary = listing - .bins - .iter() - .find(|bin| bin.bin_id == listing.primary_bin_id) - .expect("primary listing bin"); - assert_eq!( - primary.quantity.amount.to_string(), - expected["quantity_amount"], - "{id}" - ); - assert_eq!( - primary.quantity.unit.code(), - expected["quantity_unit"], - "{id}" - ); - assert_eq!( - primary.price_per_canonical_unit.amount.amount.to_string(), - expected["price_amount"], - "{id}" - ); - assert_eq!( - primary.price_per_canonical_unit.amount.currency.as_str(), - expected["price_currency"], - "{id}" - ); - assert_eq!( - serde_json::to_value( - listing - .inventory_available - .as_ref() - .map(ToString::to_string) - ) - .expect("inventory projection"), - expected["inventory_available"], - "{id}" - ); - } - "listing.parse_event.invalid" => { - let error = listing_from_nostr_event(&event) - .expect_err("invalid listing parse vector must fail"); - assert_eq!( - serde_json::to_value(error).expect("listing parse error"), - vector["expected"]["error"], - "{id}" - ); - } - kind => panic!("{id} uses unsupported listing parse vector kind {kind}"), - } -} - -fn sample_listing_full(d_tag: &str) -> RadrootsListing { - let qty_amount = RadrootsCoreDecimal::from_str("1000").unwrap(); - let price_amount = RadrootsCoreDecimal::from_str("0.01").unwrap(); - let display_qty = RadrootsCoreDecimal::from_str("1").unwrap(); - let display_price = RadrootsCoreDecimal::from_str("10").unwrap(); - - RadrootsListing { - d_tag: listing_d_tag(d_tag), - published_at: None, - farm: RadrootsFarmRef { - pubkey: "farm_pubkey".to_string(), - d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), - }, - product: RadrootsListingProduct { - key: "sku".to_string(), - title: "Widget".to_string(), - category: "Tools".to_string(), - summary: Some("Compact widget".to_string()), - process: Some("milled".to_string()), - lot: Some("lot-1".to_string()), - location: Some("Warehouse".to_string()), - profile: Some("standard".to_string()), - year: Some("2024".to_string()), - }, - primary_bin_id: bin_id("bin-1"), - bins: vec![RadrootsListingBin { - bin_id: bin_id("bin-1"), - quantity: RadrootsCoreQuantity::new(qty_amount, RadrootsCoreUnit::MassG), - price_per_canonical_unit: RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new(price_amount, RadrootsCoreCurrency::USD), - RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::MassG), - ), - display_amount: Some(display_qty), - display_unit: Some(RadrootsCoreUnit::MassKg), - display_label: Some("bag".to_string()), - display_price: Some(RadrootsCoreMoney::new( - display_price, - RadrootsCoreCurrency::USD, - )), - display_price_unit: Some(RadrootsCoreUnit::MassKg), - }], - resource_area: None, - plot: None, - discounts: Some(vec![RadrootsCoreDiscount { - scope: RadrootsCoreDiscountScope::Bin, - threshold: RadrootsCoreDiscountThreshold::BinCount { - bin_id: "bin-1".to_string(), - min: 5, - }, - value: RadrootsCoreDiscountValue::MoneyPerBin(RadrootsCoreMoney::new( - RadrootsCoreDecimal::from_str("2").unwrap(), - RadrootsCoreCurrency::USD, - )), - }]), - inventory_available: None, - availability: None, - delivery_method: None, - location: Some(RadrootsListingPublicLocation { - primary: "Moyobamba".to_string(), - city: Some("Moyobamba".to_string()), - region: Some("San Martin".to_string()), - country: Some("PE".to_string()), - geohash: "9q8yy".to_string(), - }), - images: Some(vec![RadrootsListingImage { - url: "http://example.com/widget.jpg".to_string(), - size: Some(RadrootsListingImageSize { w: 1200, h: 800 }), - }]), - } -} - -#[test] -fn listing_build_tags_requires_d_tag() { - assert!(RadrootsDTag::parse("").is_err()); -} - -#[test] -fn listing_build_tags_rejects_invalid_d_tag() { - let listing = sample_listing("invalid:tag"); - let err = listing_build_tags(&listing).unwrap_err(); - assert!(matches!(err, EventEncodeError::InvalidField("d"))); -} - -#[test] -fn listing_roundtrip_from_event() { - let listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg"); - let parts = to_wire_parts(&listing).unwrap(); - - assert_eq!(parts.content, "# Widget"); - - let decoded = listing_from_event(parts.kind, &parts.tags, &parts.content).unwrap(); - assert_eq!(decoded.d_tag, listing.d_tag); - assert_eq!(decoded.product.key, listing.product.key); - assert_eq!(decoded.product.title, listing.product.title); - assert_eq!(decoded.primary_bin_id, listing.primary_bin_id); - assert_eq!(decoded.bins.len(), listing.bins.len()); -} - -#[test] -fn listing_json_wire_parts_with_kind_serializes_listing_object() { - let listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg"); - let parts = to_json_wire_parts_with_kind(&listing, KIND_LISTING).unwrap(); - - assert_eq!(parts.kind, KIND_LISTING); - let decoded: RadrootsListing = serde_json::from_str(&parts.content).unwrap(); - assert_eq!(decoded.d_tag, listing.d_tag); - assert_eq!(decoded.product.title, listing.product.title); - assert_eq!(decoded.primary_bin_id, listing.primary_bin_id); -} - -#[test] -fn listing_json_wire_parts_with_kind_rejects_wrong_kind() { - let err = to_json_wire_parts_with_kind(&sample_listing("AAAAAAAAAAAAAAAAAAAAAg"), KIND_POST) - .unwrap_err(); - assert!(matches!(err, EventEncodeError::InvalidKind(KIND_POST))); -} - -#[test] -fn listing_json_wire_parts_with_kind_reports_tag_errors() { - let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg"); - listing.resource_area = Some(RadrootsResourceAreaRef { - pubkey: "resource_pubkey".to_string(), - d_tag: "bad d".to_string(), - }); - - let err = to_json_wire_parts_with_kind(&listing, KIND_LISTING).unwrap_err(); - assert!(matches!( - err, - EventEncodeError::InvalidField("resource_area.d_tag") - )); -} - -#[test] -fn listing_from_event_reconstructs_from_tags_with_markdown_content() { - let listing = sample_listing_full("FAAAAAAAAAAAAAAAAAAAAA"); - let tags = listing_build_tags(&listing).unwrap(); - - let decoded = listing_from_event(KIND_LISTING, &tags, "### Markdown listing").unwrap(); - assert_eq!(decoded.d_tag, listing.d_tag); - assert_eq!(decoded.product.summary, listing.product.summary); - assert_eq!(decoded.primary_bin_id, listing.primary_bin_id); - assert_eq!( - decoded - .location - .as_ref() - .map(|location| location.primary.as_str()), - Some("Moyobamba") - ); -} - -#[test] -fn listing_from_event_rejects_invalid_d_tag() { - let mut tags = listing_build_tags(&sample_listing("AAAAAAAAAAAAAAAAAAAAAg")).unwrap(); - let d_tag = tags - .iter_mut() - .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_D)) - .expect("d tag"); - d_tag[1] = "invalid:tag".to_string(); - - let err = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap_err(); - assert!(matches!(err, EventParseError::InvalidTag(TAG_D))); -} - -#[test] -fn listing_from_event_rejects_wrong_kind() { - let tags = listing_build_tags(&sample_listing("AAAAAAAAAAAAAAAAAAAAAg")).unwrap(); - - let err = listing_from_event(KIND_POST, &tags, "# Widget").unwrap_err(); - assert!(matches!( - err, - EventParseError::InvalidKind { - expected: "30402", - got: KIND_POST - } - )); -} - -#[test] -fn listing_from_event_covers_reference_tag_error_paths() { - let mut tags = sample_listing_tags(); - remove_tags(&mut tags, TAG_D); - assert_missing_tag(tags, TAG_D); - - let mut tags = sample_listing_tags(); - replace_first_tag(&mut tags, TAG_D, vec![TAG_D]); - assert_invalid_tag(tags, TAG_D); - - let mut tags = sample_listing_tags(); - replace_first_tag(&mut tags, TAG_D, vec![TAG_D, " "]); - assert_invalid_tag(tags, TAG_D); - - let mut tags = sample_listing_tags(); - remove_tags(&mut tags, "a"); - assert_missing_tag(tags, "a"); - - let mut tags = sample_listing_tags(); - replace_first_tag(&mut tags, "a", vec!["a"]); - assert_invalid_tag(tags, "a"); - - let mut tags = sample_listing_tags(); - replace_first_tag(&mut tags, "a", vec!["a", "bad:farm_pubkey:farm"]); - assert_invalid_tag(tags, "a"); - - let mut tags = sample_listing_tags(); - replace_first_tag(&mut tags, "a", vec!["a", "30340"]); - assert_invalid_tag(tags, "a"); - - let mut tags = sample_listing_tags(); - replace_first_tag(&mut tags, "a", vec!["a", "30340::farm"]); - assert_invalid_tag(tags, "a"); - - let mut tags = sample_listing_tags(); - replace_first_tag(&mut tags, "a", vec!["a", "30340:farm_pubkey:"]); - assert_invalid_tag(tags, "a"); - - let mut tags = sample_listing_tags(); - replace_first_tag(&mut tags, "a", vec!["a", "30340:farm_pubkey:bad d"]); - assert_invalid_tag(tags, "a"); - - let mut tags = sample_listing_tags(); - replace_first_tag(&mut tags, "a", vec!["a", "30023:other:article"]); - assert_missing_tag(tags, "a"); - - let mut tags = sample_listing_tags(); - remove_tags(&mut tags, "p"); - assert_missing_tag(tags, "p"); - - let mut tags = sample_listing_tags(); - replace_first_tag(&mut tags, "p", vec!["p"]); - assert_invalid_tag(tags, "p"); - - let mut tags = sample_listing_tags(); - replace_first_tag(&mut tags, "p", vec!["p", " "]); - assert_invalid_tag(tags, "p"); - - let mut tags = sample_listing_tags(); - replace_first_tag(&mut tags, "p", vec!["p", "other_pubkey"]); - assert_invalid_tag(tags, "p"); -} - -#[test] -fn listing_from_event_covers_resource_and_plot_reference_paths() { - let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAw"); - listing.resource_area = Some(RadrootsResourceAreaRef { - pubkey: "resource_pubkey".to_string(), - d_tag: "AAAAAAAAAAAAAAAAAAAABQ".to_string(), - }); - listing.plot = Some(RadrootsPlotRef { - pubkey: "plot_pubkey".to_string(), - d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_string(), - }); - let tags = listing_build_tags(&listing).unwrap(); - let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap(); - assert_eq!( - decoded - .resource_area - .as_ref() - .map(|area| area.d_tag.as_str()), - Some("AAAAAAAAAAAAAAAAAAAABQ") - ); - assert_eq!( - decoded.plot.as_ref().map(|plot| plot.d_tag.as_str()), - Some("AAAAAAAAAAAAAAAAAAAAAw") - ); - - let mut tags = sample_listing_tags(); - tags.push(vec!["radroots:resource_area".to_string()]); - assert_invalid_tag(tags, "radroots:resource_area"); - - let mut tags = sample_listing_tags(); - tags.push(vec![ - "radroots:resource_area".to_string(), - format!("{KIND_FARM}:resource_pubkey:resource-area-1"), - ]); - assert_invalid_tag(tags, "radroots:resource_area"); - - let mut tags = sample_listing_tags(); - tags.push(vec![ - "radroots:resource_area".to_string(), - format!("{KIND_RESOURCE_AREA}::resource-area-1"), - ]); - assert_invalid_tag(tags, "radroots:resource_area"); - - let mut tags = sample_listing_tags(); - tags.push(vec![ - "radroots:resource_area".to_string(), - format!("{KIND_RESOURCE_AREA}:resource_pubkey:"), - ]); - assert_invalid_tag(tags, "radroots:resource_area"); - - let mut tags = sample_listing_tags(); - tags.push(vec![ - "radroots:resource_area".to_string(), - format!("{KIND_RESOURCE_AREA}:resource_pubkey:bad d"), - ]); - assert_invalid_tag(tags, "radroots:resource_area"); - - let mut tags = sample_listing_tags(); - tags.push(vec!["radroots:plot".to_string()]); - assert_invalid_tag(tags, "radroots:plot"); - - let mut tags = sample_listing_tags(); - tags.push(vec![ - "radroots:plot".to_string(), - format!("{KIND_RESOURCE_AREA}:plot_pubkey:plot-1"), - ]); - assert_invalid_tag(tags, "radroots:plot"); - - let mut tags = sample_listing_tags(); - tags.push(vec![ - "radroots:plot".to_string(), - format!("{KIND_PLOT}:plot_pubkey:"), - ]); - assert_invalid_tag(tags, "radroots:plot"); - - let mut tags = sample_listing_tags(); - tags.push(vec![ - "radroots:plot".to_string(), - format!("{KIND_PLOT}:plot_pubkey:bad d"), - ]); - assert_invalid_tag(tags, "radroots:plot"); -} - -#[test] -fn listing_from_event_covers_bin_and_price_error_paths() { - let mut tags = sample_listing_tags(); - remove_tags(&mut tags, "radroots:primary_bin"); - assert_missing_tag(tags, "radroots:primary_bin"); - - let mut tags = sample_listing_tags(); - tags.push(vec![ - "radroots:primary_bin".to_string(), - "bin-1".to_string(), - ]); - let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap(); - assert_eq!(decoded.primary_bin_id.as_str(), "bin-1"); - - let mut tags = sample_listing_tags(); - tags.push(vec![ - "radroots:primary_bin".to_string(), - "bin-2".to_string(), - ]); - assert_invalid_tag(tags, "radroots:primary_bin"); - - let mut tags = sample_listing_tags(); - replace_first_tag( - &mut tags, - "radroots:primary_bin", - vec!["radroots:primary_bin", "bin-2"], - ); - assert_invalid_tag(tags, "radroots:primary_bin"); - - let mut tags = sample_listing_tags(); - remove_tags(&mut tags, "radroots:bin"); - assert_missing_tag(tags, "radroots:bin"); - - let mut tags = sample_listing_tags(); - remove_tags(&mut tags, "radroots:price"); - assert_missing_tag(tags, "radroots:price"); - - let mut tags = sample_listing_tags(); - replace_first_tag(&mut tags, "radroots:bin", vec!["radroots:bin"]); - assert_invalid_tag(tags, "radroots:bin"); - - let mut tags = sample_listing_tags(); - replace_first_tag( - &mut tags, - "radroots:bin", - vec!["radroots:bin", "bin-1", "1", "kg"], - ); - assert_invalid_tag(tags, "radroots:bin"); - - let mut tags = sample_listing_tags(); - replace_first_tag( - &mut tags, - "radroots:bin", - vec!["radroots:bin", "bin-1", "1", "not-a-unit"], - ); - assert_invalid_tag(tags, "radroots:bin"); - - let mut tags = sample_listing_tags(); - replace_first_tag( - &mut tags, - "radroots:bin", - vec!["radroots:bin", "bin-1", "1", "each", "1"], - ); - assert_invalid_tag(tags, "radroots:bin"); - - let mut tags = sample_listing_tags(); - replace_first_tag( - &mut tags, - "radroots:bin", - vec![ - "radroots:bin", - "bin-1", - "1", - "each", - "1", - "each", - "label", - "extra", - ], - ); - assert_invalid_tag(tags, "radroots:bin"); - - let mut tags = sample_listing_tags(); - replace_first_tag( - &mut tags, - "radroots:bin", - vec!["radroots:bin", "bin-1", "1", "each", "1", "each"], - ); - let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap(); - assert_eq!( - decoded.bins[0].display_amount, - Some(RadrootsCoreDecimal::from(1u32)) - ); - assert_eq!(decoded.bins[0].display_unit, Some(RadrootsCoreUnit::Each)); - assert_eq!(decoded.bins[0].display_label, None); - - let mut tags = sample_listing_tags(); - tags.push(vec![ - "radroots:bin".to_string(), - "bin-1".to_string(), - "1".to_string(), - "each".to_string(), - ]); - assert_invalid_tag(tags, "radroots:bin"); - - let mut tags = sample_listing_tags(); - replace_first_tag(&mut tags, "radroots:price", vec!["radroots:price"]); - assert_invalid_tag(tags, "radroots:price"); - - let mut tags = sample_listing_tags(); - replace_first_tag( - &mut tags, - "radroots:price", - vec!["radroots:price", "bin-1", "10", "USD", "1", "kg"], - ); - assert_invalid_tag(tags, "radroots:price"); - - let mut tags = sample_listing_tags(); - replace_first_tag( - &mut tags, - "radroots:price", - vec!["radroots:price", "bin-1", "10", "not-currency", "1", "each"], - ); - assert_invalid_tag(tags, "radroots:price"); - - let mut tags = sample_listing_tags(); - replace_first_tag( - &mut tags, - "radroots:price", - vec!["radroots:price", "bin-1", "10", "USD", "1", "each", "10"], - ); - assert_invalid_tag(tags, "radroots:price"); - - let mut tags = sample_listing_tags(); - replace_first_tag( - &mut tags, - "radroots:price", - vec![ - "radroots:price", - "bin-1", - "10", - "USD", - "1", - "each", - "10", - "each", - "extra", - ], - ); - assert_invalid_tag(tags, "radroots:price"); - - let mut tags = sample_listing_tags(); - tags.push(vec![ - "radroots:price".to_string(), - "bin-1".to_string(), - "10".to_string(), - "USD".to_string(), - "1".to_string(), - "each".to_string(), - ]); - assert_invalid_tag(tags, "radroots:price"); - - let mut tags = sample_listing_tags(); - replace_first_tag( - &mut tags, - "radroots:price", - vec!["radroots:price", "bin-1", "10", "USD", "1", "g"], - ); - assert_invalid_tag(tags, "radroots:price"); -} - -#[test] -fn listing_from_event_covers_trade_location_delivery_and_image_paths() { - for expected in ["dd", "dd.lat", "dd.lon", "l", "L"] { - let mut tags = sample_listing_tags(); - tags.push(vec![expected.to_string(), "synthetic".to_string()]); - assert_invalid_tag(tags, expected); - } - - let mut tags = sample_listing_tags(); - tags.push(vec!["location".to_string(), "Farm shelf".to_string()]); - let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap(); - assert_eq!(decoded.product.location.as_deref(), Some("Farm shelf")); - assert!(decoded.location.is_none()); - - let mut tags = sample_listing_tags(); - tags.push(vec!["location".to_string(), "Farm shelf".to_string()]); - tags.push(vec![ - "location".to_string(), - "Peru".to_string(), - "Moyobamba".to_string(), - "San Martin".to_string(), - "PE".to_string(), - ]); - tags.push(vec!["g".to_string(), "6gkzw".to_string()]); - let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap(); - assert_eq!(decoded.product.location.as_deref(), Some("Farm shelf")); - assert_eq!( - decoded.location.as_ref().map(|location| { - ( - location.primary.as_str(), - location.city.as_deref(), - location.geohash.as_str(), - ) - }), - Some(("Peru", Some("Moyobamba"), "6gkzw")) - ); - - let mut tags = sample_listing_tags(); - tags.push(vec![ - "location".to_string(), - " ".to_string(), - "Moyobamba".to_string(), - ]); - assert_invalid_tag(tags, "location"); - - let mut tags = sample_listing_tags(); - tags.push(vec![ - "location".to_string(), - "Farm stand".to_string(), - " ".to_string(), - "null".to_string(), - " ".to_string(), - ]); - tags.push(vec!["g".to_string(), "9q8yy".to_string()]); - let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap(); - assert_eq!( - decoded.location.as_ref().map(|location| { - ( - location.primary.as_str(), - location.city.as_deref(), - location.region.as_deref(), - location.country.as_deref(), - location.geohash.as_str(), - ) - }), - Some(("Farm stand", None, None, None, "9q8yy")) - ); - - let mut tags = sample_listing_tags(); - tags.push(vec!["g".to_string(), "9q8ya".to_string()]); - assert_invalid_tag(tags, "g"); - - let mut tags = sample_listing_tags(); - tags.push(vec!["g".to_string(), "9q8yy".to_string()]); - tags.push(vec!["g".to_string(), "6gkzw".to_string()]); - assert_invalid_tag(tags, "g"); - - let mut tags = sample_listing_tags(); - tags.push(vec!["inventory".to_string()]); - assert_invalid_tag(tags, "inventory"); - - let mut tags = sample_listing_tags(); - tags.push(vec!["inventory".to_string(), "bad".to_string()]); - assert_invalid_tag(tags, "inventory"); - - let mut tags = sample_listing_tags(); - tags.push(vec!["inventory".to_string(), "12.5".to_string()]); - tags.push(vec![ - "radroots:availability_start".to_string(), - "1730".to_string(), - ]); - tags.push(vec!["expires_at".to_string(), "1740".to_string()]); - tags.push(vec!["delivery".to_string(), "pickup".to_string()]); - tags.push(vec!["image".to_string(), " ".to_string()]); - tags.push(vec![ - "image".to_string(), - "https://example.test/a.jpg".to_string(), - ]); - tags.push(vec![ - "image".to_string(), - "https://example.test/b.jpg".to_string(), - "bad-size".to_string(), - ]); - let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap(); - let Some(RadrootsListingAvailability::Window { start, end }) = decoded.availability else { - panic!("expected availability window"); - }; - assert_eq!(start, Some(1730)); - assert_eq!(end, Some(1740)); - assert!(matches!( - decoded.delivery_method, - Some(RadrootsListingDeliveryMethod::Pickup) - )); - assert_eq!(decoded.images.as_ref().map(Vec::len), Some(2)); - assert!(decoded.images.as_ref().unwrap()[1].size.is_none()); - - let mut tags = sample_listing_tags(); - tags.push(vec!["delivery".to_string(), "local_delivery".to_string()]); - let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap(); - assert!(matches!( - decoded.delivery_method, - Some(RadrootsListingDeliveryMethod::LocalDelivery) - )); - - let mut tags = sample_listing_tags(); - tags.push(vec!["delivery".to_string(), "shipping".to_string()]); - let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap(); - assert!(matches!( - decoded.delivery_method, - Some(RadrootsListingDeliveryMethod::Shipping) - )); - - let mut tags = sample_listing_tags(); - tags.push(vec![ - "delivery".to_string(), - "other".to_string(), - "bike courier".to_string(), - ]); - let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap(); - let Some(RadrootsListingDeliveryMethod::Other { method }) = decoded.delivery_method else { - panic!("expected other delivery method"); - }; - assert_eq!(method, "bike courier"); - - let mut tags = sample_listing_tags(); - tags.push(vec!["delivery".to_string(), "drone".to_string()]); - let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap(); - let Some(RadrootsListingDeliveryMethod::Other { method }) = decoded.delivery_method else { - panic!("expected fallback delivery method"); - }; - assert_eq!(method, "drone"); - - let mut tags = sample_listing_tags(); - tags.push(vec!["status".to_string(), "active".to_string()]); - let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap(); - assert!(matches!( - decoded.availability, - Some(RadrootsListingAvailability::Status { - status: RadrootsListingStatus::Active - }) - )); - - let mut tags = sample_listing_tags(); - tags.push(vec!["status".to_string(), "sold".to_string()]); - let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap(); - assert!(matches!( - decoded.availability, - Some(RadrootsListingAvailability::Status { - status: RadrootsListingStatus::Sold - }) - )); - - let mut tags = sample_listing_tags(); - tags.push(vec!["status".to_string(), "paused".to_string()]); - let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap(); - let Some(RadrootsListingAvailability::Status { - status: RadrootsListingStatus::Other { value }, - }) = decoded.availability - else { - panic!("expected other availability status"); - }; - assert_eq!(value, "paused"); -} - -#[test] -fn listing_from_event_rejects_private_location_content_edges() { - let tags = sample_listing_tags(); - for content in [ - "# Widget", - "{not-json", - r#"{"name":"Widget"}"#, - r#"{"location":{"public_label":"Farm shelf"}}"#, - ] { - let decoded = listing_from_event(KIND_LISTING, &tags, content).unwrap(); - assert_eq!(decoded.product.key, "sku"); - } - - for key in [ - "lat", - "lng", - "lon", - "point", - "polygon", - "coordinates", - "accuracy", - "altitude", - "label", - "tag_0", - "gcs", - ] { - let content = format!(r#"{{"location":{{"{key}":"secret"}}}}"#); - let err = listing_from_event(KIND_LISTING, &tags, &content).unwrap_err(); - assert!(matches!(err, EventParseError::InvalidJson("content"))); - } -} - -#[test] -fn listing_from_event_covers_remaining_edge_paths() { - let mut tags = sample_listing_tags(); - tags.insert(0, Vec::new()); - tags.push(vec!["location".to_string()]); - let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap(); - assert_eq!(decoded.product.location, None); - - let mut tags = sample_listing_tags(); - tags.push(vec![ - "radroots:plot".to_string(), - format!("{KIND_PLOT}::AAAAAAAAAAAAAAAAAAAAAw"), - ]); - assert_invalid_tag(tags, "radroots:plot"); - - let mut tags = sample_listing_tags(); - tags.push(vec![ - "radroots:primary_bin".to_string(), - "bin-2".to_string(), - ]); - assert_invalid_tag(tags, "radroots:primary_bin"); - - let mut tags = sample_listing_tags(); - let primary_position = tags - .iter() - .position(|tag| tag.first().map(String::as_str) == Some("radroots:primary_bin")) - .expect("primary bin tag"); - tags.insert( - primary_position + 1, - vec!["radroots:primary_bin".to_string(), "bin-2".to_string()], - ); - assert_invalid_tag(tags, "radroots:primary_bin"); - - let mut tags = sample_listing_tags(); - tags.insert(0, vec!["key".to_string(), " ".to_string()]); - tags.push(vec!["key".to_string(), "ignored".to_string()]); - tags.insert(0, vec!["summary".to_string(), " ".to_string()]); - tags.push(vec!["summary".to_string(), "first summary".to_string()]); - tags.push(vec!["summary".to_string(), "ignored summary".to_string()]); - tags.push(vec!["process".to_string(), "null".to_string()]); - tags.push(vec!["lot".to_string(), " null ".to_string()]); - tags.push(vec!["profile".to_string(), "null".to_string()]); - tags.push(vec!["year".to_string(), "null".to_string()]); - let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap(); - assert_eq!(decoded.product.key, "sku"); - assert_eq!(decoded.product.summary.as_deref(), Some("first summary")); - assert_eq!(decoded.product.process, None); - assert_eq!(decoded.product.lot, None); - assert_eq!(decoded.product.profile, None); - assert_eq!(decoded.product.year, None); - - let mut tags = sample_listing_tags(); - tags.push(vec!["radroots:availability_start".to_string()]); - assert_invalid_tag(tags, "radroots:availability_start"); - - let mut tags = sample_listing_tags(); - tags.push(vec![ - "radroots:availability_start".to_string(), - "bad".to_string(), - ]); - assert_invalid_tag(tags, "radroots:availability_start"); -} - -#[test] -fn listing_parsed_wrappers_preserve_event_metadata() { - let listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAQ"); - let parts = to_wire_parts(&listing).unwrap(); - let data = data_from_event( - EVENT_ID.to_string(), - AUTHOR.to_string(), - 7, - parts.kind, - parts.content.clone(), - parts.tags.clone(), - ) - .unwrap(); - assert_eq!(data.id, EVENT_ID); - assert_eq!(data.author, AUTHOR); - assert_eq!(data.published_at, 7); - assert_eq!(data.kind, KIND_LISTING); - assert_eq!(data.data.d_tag, listing.d_tag); - - let parsed = parsed_from_event( - EVENT_ID.to_string(), - AUTHOR.to_string(), - 7, - parts.kind, - parts.content.clone(), - parts.tags.clone(), - EVENT_SIG.to_string(), - ) - .unwrap(); - assert_eq!(parsed.event.id_str(), EVENT_ID); - assert_eq!(parsed.event.author_str(), AUTHOR); - assert_eq!(parsed.event.created_at_u64(), 7); - assert_eq!(parsed.event.sig_str(), EVENT_SIG); - assert_eq!(parsed.data.data.d_tag, listing.d_tag); - - let event = event_envelope(parts.kind, parts.tags, parts.content); - let data = data_from_nostr_event(&event).unwrap(); - assert_eq!(data.data.d_tag, listing.d_tag); - let parsed = parsed_from_nostr_event(&event).unwrap(); - assert_eq!(parsed.event.sig_str(), EVENT_SIG); - assert_eq!(parsed.data.data.d_tag, listing.d_tag); - - let err = parsed_from_event( - EVENT_ID.to_string(), - AUTHOR.to_string(), - 7, - KIND_POST, - event.content().to_string(), - event.tags_as_vec(), - EVENT_SIG.to_string(), - ) - .unwrap_err(); - assert!(matches!( - err, - EventParseError::InvalidKind { - expected: "30402", - got: KIND_POST - } - )); -} - -#[test] -fn retired_listing_kind_is_rejected() { - let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAQ"); - listing.published_at = Some(1_781_895_600); - - assert!(matches!( - to_wire_parts_with_kind(&listing, 30403), - Err(EventEncodeError::InvalidKind(30403)) - )); -} - -#[test] -fn listing_roundtrips_published_at_for_active_and_rejects_bad_value() { - let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg"); - listing.published_at = Some(1_781_895_600); - let parts = to_wire_parts_with_kind(&listing, KIND_LISTING).unwrap(); - assert!(parts.tags.iter().any(|tag| { - tag.first().map(|value| value.as_str()) == Some(TAG_PUBLISHED_AT) - && tag.get(1).map(|value| value.as_str()) == Some("1781895600") - })); - - let decoded = listing_from_event(parts.kind, &parts.tags, &parts.content).unwrap(); - assert_eq!(decoded.published_at, Some(1_781_895_600)); - - let mut tags = parts.tags; - let published_at = tags - .iter_mut() - .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_PUBLISHED_AT)) - .expect("published_at tag"); - published_at[1] = "bad".to_string(); - let err = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap_err(); - assert!(matches!(err, EventParseError::InvalidTag(TAG_PUBLISHED_AT))); -} - -#[test] -fn to_wire_parts_rejects_non_listing_kind() { - let err = - to_wire_parts_with_kind(&sample_listing("AAAAAAAAAAAAAAAAAAAAAg"), KIND_POST).unwrap_err(); - assert!(matches!(err, EventEncodeError::InvalidKind(KIND_POST))); -} - -#[test] -fn listing_build_tags_includes_listing_fields() { - let listing = sample_listing_full("AAAAAAAAAAAAAAAAAAAAAg"); - let tags = listing_build_tags(&listing).unwrap(); - - assert!(tags.iter().any(|t| { - t.first().map(|s| s.as_str()) == Some(TAG_D) - && t.get(1).map(|s| s.as_str()) == Some("AAAAAAAAAAAAAAAAAAAAAg") - })); - assert!(tags.iter().any(|t| { - t.first().map(|s| s.as_str()) == Some("p") - && t.get(1).map(|s| s.as_str()) == Some("farm_pubkey") - })); - assert!(tags.iter().any(|t| { - t.first().map(|s| s.as_str()) == Some("a") - && t.get(1).map(|s| s.as_str()) == Some("30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA") - })); - assert!(tags.iter().any(|t| { - t.first().map(|s| s.as_str()) == Some("key") && t.get(1).map(|s| s.as_str()) == Some("sku") - })); - assert!(tags.iter().any(|t| { - t.first().map(|s| s.as_str()) == Some("title") - && t.get(1).map(|s| s.as_str()) == Some("Widget") - })); - - let primary_tag = tags - .iter() - .find(|t| t.first().map(|s| s.as_str()) == Some("radroots:primary_bin")) - .expect("primary bin tag"); - assert_eq!(primary_tag.get(1).map(|s| s.as_str()), Some("bin-1")); - - let bin_tag = tags - .iter() - .find(|t| t.first().map(|s| s.as_str()) == Some("radroots:bin")) - .expect("bin tag"); - assert_eq!(bin_tag.get(1).map(|s| s.as_str()), Some("bin-1")); - assert_eq!(bin_tag.get(2).map(|s| s.as_str()), Some("1000")); - assert_eq!(bin_tag.get(3).map(|s| s.as_str()), Some("g")); - assert_eq!(bin_tag.get(4).map(|s| s.as_str()), Some("1")); - assert_eq!(bin_tag.get(5).map(|s| s.as_str()), Some("kg")); - assert_eq!(bin_tag.get(6).map(|s| s.as_str()), Some("bag")); - - let price_tag = tags - .iter() - .find(|t| t.first().map(|s| s.as_str()) == Some("radroots:price")) - .expect("radroots price tag"); - assert_eq!(price_tag.get(1).map(|s| s.as_str()), Some("bin-1")); - assert_eq!(price_tag.get(2).map(|s| s.as_str()), Some("0.01")); - assert_eq!(price_tag.get(3).map(|s| s.as_str()), Some("USD")); - assert_eq!(price_tag.get(4).map(|s| s.as_str()), Some("1")); - assert_eq!(price_tag.get(5).map(|s| s.as_str()), Some("g")); - assert_eq!(price_tag.get(6).map(|s| s.as_str()), Some("10")); - assert_eq!(price_tag.get(7).map(|s| s.as_str()), Some("kg")); - - let generic_price_tag = tags - .iter() - .find(|t| { - t.first().map(|s| s.as_str()) == Some("price") - && t.get(1).map(|s| s.as_str()) == Some("10") - }) - .expect("generic price tag"); - assert_eq!(generic_price_tag.get(2).map(|s| s.as_str()), Some("USD")); - - let discount_tag = tags - .iter() - .find(|t| t.first().map(|s| s.as_str()) == Some("radroots:discount")) - .expect("discount tag"); - assert!( - discount_tag - .get(1) - .map(|s| s.contains("\"scope\":\"bin\"")) - .unwrap_or(false) - ); - - assert!(tags.iter().any(|t| { - t.first().map(|s| s.as_str()) == Some("location") - && t.get(1).map(|s| s.as_str()) == Some("Moyobamba") - })); - - let g_tags: Vec<&Vec<String>> = tags - .iter() - .filter(|t| t.first().map(|s| s.as_str()) == Some("g")) - .collect(); - assert_eq!(g_tags.len(), 1); - assert_eq!(g_tags[0][1].len(), 5); - assert!( - !tags - .iter() - .any(|t| t.first().map(|s| s.as_str()) == Some("L")) - ); - assert!( - !tags - .iter() - .any(|t| t.first().map(|s| s.as_str()) == Some("l")) - ); - - assert!(tags.iter().any(|t| { - t.first().map(|s| s.as_str()) == Some("image") - && t.get(1).map(|s| s.as_str()) == Some("http://example.com/widget.jpg") - && t.get(2).map(|s| s.as_str()) == Some("1200x800") - })); -} - -#[test] -fn listing_tags_full_uses_single_generic_price_for_primary_bin() { - let mut listing = sample_listing_full("AAAAAAAAAAAAAAAAAAAAAw"); - listing.bins.push(RadrootsListingBin { - bin_id: bin_id("bin-2"), - quantity: RadrootsCoreQuantity::new( - RadrootsCoreDecimal::from_str("500").unwrap(), - RadrootsCoreUnit::MassG, - ), - price_per_canonical_unit: RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new( - RadrootsCoreDecimal::from_str("0.02").unwrap(), - RadrootsCoreCurrency::USD, - ), - RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::MassG), - ), - display_amount: Some(RadrootsCoreDecimal::from(500u32)), - display_unit: Some(RadrootsCoreUnit::MassG), - display_label: Some("sample".to_string()), - display_price: Some(RadrootsCoreMoney::new( - RadrootsCoreDecimal::from_str("10").unwrap(), - RadrootsCoreCurrency::USD, - )), - display_price_unit: Some(RadrootsCoreUnit::MassG), - }); - - let tags = listing_tags_full(&listing).unwrap(); - let generic_price_tags: Vec<&Vec<String>> = tags - .iter() - .filter(|tag| tag.first().map(|value| value.as_str()) == Some("price")) - .collect(); - assert_eq!(generic_price_tags.len(), 1); - assert_eq!( - generic_price_tags[0].get(1).map(|value| value.as_str()), - Some("10") - ); - assert_eq!( - generic_price_tags[0].get(2).map(|value| value.as_str()), - Some("USD") - ); -} - -#[test] -fn listing_tags_full_includes_trade_fields() { - let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg"); - let inventory = RadrootsCoreDecimal::from_str("12.5").unwrap(); - let inventory_value = inventory.to_string(); - listing.inventory_available = Some(inventory); - listing.availability = Some(RadrootsListingAvailability::Window { - start: Some(1730000000), - end: Some(1731000000), - }); - listing.delivery_method = Some(RadrootsListingDeliveryMethod::Shipping); - - let tags = listing_tags_full(&listing).unwrap(); - - assert!(tags.iter().any(|t| { - t.first().map(|s| s.as_str()) == Some("inventory") - && t.get(1).map(|s| s.as_str()) == Some(inventory_value.as_str()) - })); - assert!(tags.iter().any(|t| { - t.first().map(|s| s.as_str()) == Some("radroots:availability_start") - && t.get(1).map(|s| s.as_str()) == Some("1730000000") - })); - assert!(tags.iter().any(|t| { - t.first().map(|s| s.as_str()) == Some("expires_at") - && t.get(1).map(|s| s.as_str()) == Some("1731000000") - })); - assert!(tags.iter().any(|t| { - t.first().map(|s| s.as_str()) == Some("delivery") - && t.get(1).map(|s| s.as_str()) == Some("shipping") - })); -} - -#[test] -fn listing_tags_full_includes_status_tag() { - let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg"); - listing.availability = Some(RadrootsListingAvailability::Status { - status: RadrootsListingStatus::Active, - }); - - let tags = listing_tags_full(&listing).unwrap(); - - assert!(tags.iter().any(|t| { - t.first().map(|s| s.as_str()) == Some("status") - && t.get(1).map(|s| s.as_str()) == Some("active") - })); -} - -#[test] -fn listing_build_tags_ignores_null_strings() { - let mut listing = sample_listing_full("AAAAAAAAAAAAAAAAAAAAAg"); - listing.product.summary = Some("null".to_string()); - listing.product.process = Some("null".to_string()); - listing.product.lot = Some("null".to_string()); - listing.product.location = Some("null".to_string()); - listing.product.profile = Some("null".to_string()); - listing.product.year = Some("null".to_string()); - listing.location = Some(RadrootsListingPublicLocation { - primary: "Moyobamba".to_string(), - city: Some("null".to_string()), - region: Some("San Martin".to_string()), - country: Some("null".to_string()), - geohash: "9q8yy".to_string(), - }); - listing.images = Some(vec![RadrootsListingImage { - url: "null".to_string(), - size: None, - }]); - - let tags = listing_build_tags(&listing).unwrap(); - assert!( - !tags - .iter() - .any(|tag| tag.iter().any(|value| value == "null")) - ); -} - -#[test] -fn listing_build_tags_rejects_location_without_public_locality() { - let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg"); - listing.location = Some(RadrootsListingPublicLocation { - primary: "Farm stand".to_string(), - city: Some("null".to_string()), - region: None, - country: None, - geohash: "9q8yy".to_string(), - }); - - assert!(matches!( - listing_build_tags(&listing), - Err(EventEncodeError::EmptyRequiredField("location.locality")) - )); -} - -#[test] -fn listing_tags_with_options_cover_location_fallback_paths() { - let mut geohash_only = sample_listing("AAAAAAAAAAAAAAAAAAAAAg"); - geohash_only.location = Some(RadrootsListingPublicLocation { - primary: "Moyobamba".to_string(), - city: Some("Moyobamba".to_string()), - region: None, - country: None, - geohash: "6gkzw".to_string(), - }); - let tags = listing_tags_with_options(&geohash_only, ListingTagOptions::default()).unwrap(); - assert!( - tags.iter() - .any(|tag| tag.first().map(|value| value.as_str()) == Some("g")) - ); - assert!( - !tags - .iter() - .any(|tag| tag.first().map(|value| value.as_str()) == Some("l")) - ); - - let mut no_coordinates = sample_listing("AAAAAAAAAAAAAAAAAAAAAQ"); - no_coordinates.location = Some(RadrootsListingPublicLocation { - primary: "Moyobamba".to_string(), - city: Some("Moyobamba".to_string()), - region: None, - country: None, - geohash: "9q8yy".to_string(), - }); - let tags = listing_tags_with_options(&no_coordinates, ListingTagOptions::default()).unwrap(); - assert!( - !tags - .iter() - .any(|tag| tag.first().map(|value| value.as_str()) == Some("L")) - ); - assert!( - tags.iter() - .any(|tag| tag.first().map(|value| value.as_str()) == Some("g")) - ); - - let mut without_geohash = sample_listing("AAAAAAAAAAAAAAAAAAAAAw"); - without_geohash.location = Some(RadrootsListingPublicLocation { - primary: "Moyobamba".to_string(), - city: Some("Moyobamba".to_string()), - region: None, - country: None, - geohash: "9q8yy".to_string(), - }); - let tags = listing_tags_with_options( - &without_geohash, - ListingTagOptions { - ..ListingTagOptions::default() - }, - ) - .unwrap(); - assert!( - tags.iter() - .any(|tag| tag.first().map(|value| value.as_str()) == Some("g")) - ); - assert!( - !tags - .iter() - .any(|tag| tag.first().map(|value| value.as_str()) == Some("L")) - ); -} diff --git a/crates/event_codec/tests/operational_listing.rs b/crates/event_codec/tests/operational_listing.rs @@ -0,0 +1,1530 @@ +#![cfg(feature = "serde_json")] + +use radroots_core::{ + RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreDiscountScope, + RadrootsCoreDiscountThreshold, RadrootsCoreDiscountValue, RadrootsCoreMoney, + RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit, +}; +use radroots_event::{ + RadrootsEventEnvelope, RadrootsEventEnvelopeParts, + farm::RadrootsFarmRef, + ids::{RadrootsDTag, RadrootsInventoryBinId}, + kinds::{KIND_CLASSIFIED_LISTING, KIND_FARM, KIND_PLOT, KIND_POST, KIND_RESOURCE_AREA}, + operational_listing::RadrootsOperationalListingParseError, + operational_listing::{ + RadrootsOperationalListing, RadrootsOperationalListingAvailability, + RadrootsOperationalListingBin, RadrootsOperationalListingDeliveryMethod, + RadrootsOperationalListingImage, RadrootsOperationalListingImageSize, + RadrootsOperationalListingProduct, RadrootsOperationalListingPublicLocation, + RadrootsOperationalListingStatus, + }, + plot::RadrootsPlotRef, + resource_area::RadrootsResourceAreaRef, + tags::{TAG_D, TAG_PUBLISHED_AT}, +}; +use radroots_event_codec::error::{EventEncodeError, EventParseError}; +use radroots_event_codec::operational_listing::decode::{ + data_from_event, data_from_nostr_event, operational_listing_from_event, + operational_listing_from_event_parts, operational_listing_from_nostr_event, parsed_from_event, + parsed_from_nostr_event, +}; +use radroots_event_codec::operational_listing::encode::{ + operational_listing_build_tags, to_wire_parts, to_wire_parts_with_kind, +}; +use radroots_event_codec::operational_listing::tags::{ + OperationalListingTagOptions, operational_listing_tags_full, + operational_listing_tags_with_options, +}; +use std::{borrow::Cow, collections::BTreeSet, fs, path::Path, str::FromStr}; + +use serde_json::Value; + +const EVENT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const AUTHOR: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const EVENT_SIG: &str = concat!( + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" +); +const PACKAGED_PARSE_VECTORS: &str = + include_str!("fixtures/operational_listing_parse_event.v1.json"); +const WORKSPACE_PARSE_VECTOR_PATH: &str = + "../../contracts/conformance/vectors/operational_listing/parse_event.v1.json"; +const WORKSPACE_CONTRACT_MARKER_PATH: &str = "../../contracts/manifest.toml"; + +fn listing_d_tag(raw: &str) -> RadrootsDTag { + raw.parse().unwrap() +} + +fn bin_id(raw: &str) -> RadrootsInventoryBinId { + raw.parse().unwrap() +} + +fn sample_operational_listing_tags() -> Vec<Vec<String>> { + operational_listing_build_tags(&sample_listing("AAAAAAAAAAAAAAAAAAAAAg")).unwrap() +} + +fn remove_tags(tags: &mut Vec<Vec<String>>, name: &str) { + tags.retain(|tag| tag.first().map(|value| value.as_str()) != Some(name)); +} + +fn replace_first_tag(tags: &mut [Vec<String>], name: &str, replacement: Vec<&str>) { + let tag = tags + .iter_mut() + .find(|tag| tag.first().map(|value| value.as_str()) == Some(name)) + .expect("tag"); + *tag = replacement.into_iter().map(str::to_string).collect(); +} + +fn event_envelope(kind: u32, tags: Vec<Vec<String>>, content: String) -> RadrootsEventEnvelope { + RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { + id: EVENT_ID.to_string(), + author: AUTHOR.to_string(), + created_at: 7, + kind, + tags, + content, + sig: EVENT_SIG.to_string(), + }) + .unwrap() +} + +fn assert_missing_tag(tags: Vec<Vec<String>>, expected: &'static str) { + match operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget") { + Err(EventParseError::MissingTag(tag)) => assert_eq!(tag, expected), + other => panic!("expected missing tag {expected}: {other:?}"), + } +} + +fn assert_invalid_tag(tags: Vec<Vec<String>>, expected: &'static str) { + match operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget") { + Err(EventParseError::InvalidTag(tag)) => assert_eq!(tag, expected), + other => panic!("expected invalid tag {expected}: {other:?}"), + } +} + +fn sample_listing(d_tag: &str) -> RadrootsOperationalListing { + let quantity = + RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::Each); + let price = RadrootsCoreQuantityPrice::new( + RadrootsCoreMoney::new(RadrootsCoreDecimal::from(10u32), RadrootsCoreCurrency::USD), + quantity.clone(), + ); + + RadrootsOperationalListing { + d_tag: listing_d_tag(d_tag), + published_at: None, + farm: RadrootsFarmRef { + pubkey: "farm_pubkey".to_string(), + d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), + }, + product: RadrootsOperationalListingProduct { + key: "sku".to_string(), + title: "Widget".to_string(), + category: "Tools".to_string(), + summary: None, + process: None, + lot: None, + location: None, + profile: None, + year: None, + }, + primary_bin_id: bin_id("bin-1"), + bins: vec![RadrootsOperationalListingBin { + bin_id: bin_id("bin-1"), + quantity, + price_per_canonical_unit: price, + display_amount: None, + display_unit: None, + display_label: None, + display_price: None, + display_price_unit: None, + }], + resource_area: None, + plot: None, + discounts: None, + inventory_available: None, + availability: None, + delivery_method: None, + location: None, + images: None, + } +} + +#[test] +fn operational_listing_from_event_parts_preserves_listing_error_taxonomy() { + let mut tags = sample_operational_listing_tags(); + remove_tags(&mut tags, TAG_D); + assert_eq!( + operational_listing_from_event_parts(&tags, "").unwrap_err(), + RadrootsOperationalListingParseError::MissingTag(TAG_D.to_string()) + ); + + let mut tags = sample_operational_listing_tags(); + replace_first_tag(&mut tags, TAG_D, vec![TAG_D, "bad d"]); + assert_eq!( + operational_listing_from_event_parts(&tags, "").unwrap_err(), + RadrootsOperationalListingParseError::InvalidTag(TAG_D.to_string()) + ); + + let mut tags = sample_operational_listing_tags(); + replace_first_tag( + &mut tags, + "radroots:bin", + vec!["radroots:bin", "bin-1", "bad", "each"], + ); + assert_eq!( + operational_listing_from_event_parts(&tags, "").unwrap_err(), + RadrootsOperationalListingParseError::InvalidNumber("radroots:bin".to_string()) + ); + + let mut tags = sample_operational_listing_tags(); + replace_first_tag( + &mut tags, + "radroots:bin", + vec!["radroots:bin", "bin-1", "1", "bad"], + ); + assert_eq!( + operational_listing_from_event_parts(&tags, "").unwrap_err(), + RadrootsOperationalListingParseError::InvalidUnit + ); + + let mut tags = sample_operational_listing_tags(); + replace_first_tag( + &mut tags, + "radroots:price", + vec!["radroots:price", "bin-1", "10", "not-currency", "1", "each"], + ); + assert_eq!( + operational_listing_from_event_parts(&tags, "").unwrap_err(), + RadrootsOperationalListingParseError::InvalidCurrency + ); + + let mut tags = sample_operational_listing_tags(); + tags.push(vec!["radroots:discount".to_string(), "{".to_string()]); + assert_eq!( + operational_listing_from_event_parts(&tags, "").unwrap_err(), + RadrootsOperationalListingParseError::InvalidDiscount("radroots:discount".to_string()) + ); + + assert_eq!( + operational_listing_from_event_parts( + &sample_operational_listing_tags(), + r#"{"location":{"coordinates":[1,2]}}"#, + ) + .unwrap_err(), + RadrootsOperationalListingParseError::InvalidJson("location".to_string()) + ); +} + +#[test] +fn operational_listing_from_event_parts_does_not_allow_json_content_to_override_tags() { + let tags = sample_operational_listing_tags(); + let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg"); + listing.product.title = "Content override".to_string(); + listing.product.category = "Content category".to_string(); + listing.inventory_available = Some(RadrootsCoreDecimal::from(99u32)); + listing.bins[0].quantity.amount = RadrootsCoreDecimal::from(42u32); + + let decoded = + operational_listing_from_event_parts(&tags, &serde_json::to_string(&listing).unwrap()) + .expect("decode tag-authoritative listing"); + + assert_eq!(decoded.product.title, "Widget"); + assert_eq!(decoded.product.category, "Tools"); + assert_eq!(decoded.inventory_available, None); + assert_eq!( + decoded.bins[0].quantity.amount, + RadrootsCoreDecimal::from(1u32) + ); +} + +#[test] +fn checked_in_listing_parse_vectors_execute_against_the_typed_decoder() { + let vectors = listing_parse_vectors(); + let suite: Value = serde_json::from_str(&vectors).expect("listing parse vectors must parse"); + assert_eq!(suite["suite"], "operational_listing"); + assert_eq!(suite["contract_version"], "1.0.0"); + let vectors = suite["vectors"].as_array().expect("listing parse vectors"); + assert_eq!(vectors.len(), 9, "listing parse vector inventory drifted"); + + let mut ids = BTreeSet::new(); + for vector in vectors { + let id = vector["id"].as_str().expect("vector id"); + assert!(ids.insert(id), "duplicate listing parse vector id {id}"); + execute_listing_parse_vector(vector, id); + } +} + +fn listing_parse_vectors() -> Cow<'static, str> { + let workspace_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(WORKSPACE_PARSE_VECTOR_PATH); + match fs::read_to_string(&workspace_path) { + Ok(canonical) => { + assert_eq!( + canonical, + PACKAGED_PARSE_VECTORS, + "packaged listing parse vectors must match {}", + workspace_path.display() + ); + Cow::Owned(canonical) + } + Err(error) + if error.kind() == std::io::ErrorKind::NotFound + && !Path::new(env!("CARGO_MANIFEST_DIR")) + .join(WORKSPACE_CONTRACT_MARKER_PATH) + .is_file() => + { + Cow::Borrowed(PACKAGED_PARSE_VECTORS) + } + Err(error) => panic!("failed to read {}: {error}", workspace_path.display()), + } +} + +fn execute_listing_parse_vector(vector: &Value, id: &str) { + let input = &vector["input"]; + let tags: Vec<Vec<String>> = + serde_json::from_value(input["tags"].clone()).expect("vector tags"); + let content = input["content"].as_str().expect("vector content"); + let event_kind = input["event_kind"].as_u64().expect("vector event kind") as u32; + let event = event_envelope(event_kind, tags, content.to_string()); + + match vector["kind"].as_str().expect("vector kind") { + "operational_listing.parse_event.valid" => { + let listing = operational_listing_from_nostr_event(&event) + .unwrap_or_else(|error| panic!("{id} failed: {error}")); + let expected = &vector["expected"]["listing"]; + assert_eq!(listing.d_tag.as_str(), expected["d_tag"], "{id}"); + assert_eq!(listing.product.title, expected["title"], "{id}"); + assert_eq!(listing.product.category, expected["category"], "{id}"); + assert_eq!( + listing.primary_bin_id.as_str(), + expected["primary_bin_id"], + "{id}" + ); + let primary = listing + .bins + .iter() + .find(|bin| bin.bin_id == listing.primary_bin_id) + .expect("primary listing bin"); + assert_eq!( + primary.quantity.amount.to_string(), + expected["quantity_amount"], + "{id}" + ); + assert_eq!( + primary.quantity.unit.code(), + expected["quantity_unit"], + "{id}" + ); + assert_eq!( + primary.price_per_canonical_unit.amount.amount.to_string(), + expected["price_amount"], + "{id}" + ); + assert_eq!( + primary.price_per_canonical_unit.amount.currency.as_str(), + expected["price_currency"], + "{id}" + ); + assert_eq!( + serde_json::to_value( + listing + .inventory_available + .as_ref() + .map(ToString::to_string) + ) + .expect("inventory projection"), + expected["inventory_available"], + "{id}" + ); + } + "operational_listing.parse_event.invalid" => { + let error = operational_listing_from_nostr_event(&event) + .expect_err("invalid listing parse vector must fail"); + assert_eq!( + serde_json::to_value(error).expect("listing parse error"), + vector["expected"]["error"], + "{id}" + ); + } + kind => panic!("{id} uses unsupported listing parse vector kind {kind}"), + } +} + +fn sample_listing_full(d_tag: &str) -> RadrootsOperationalListing { + let qty_amount = RadrootsCoreDecimal::from_str("1000").unwrap(); + let price_amount = RadrootsCoreDecimal::from_str("0.01").unwrap(); + let display_qty = RadrootsCoreDecimal::from_str("1").unwrap(); + let display_price = RadrootsCoreDecimal::from_str("10").unwrap(); + + RadrootsOperationalListing { + d_tag: listing_d_tag(d_tag), + published_at: None, + farm: RadrootsFarmRef { + pubkey: "farm_pubkey".to_string(), + d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), + }, + product: RadrootsOperationalListingProduct { + key: "sku".to_string(), + title: "Widget".to_string(), + category: "Tools".to_string(), + summary: Some("Compact widget".to_string()), + process: Some("milled".to_string()), + lot: Some("lot-1".to_string()), + location: Some("Warehouse".to_string()), + profile: Some("standard".to_string()), + year: Some("2024".to_string()), + }, + primary_bin_id: bin_id("bin-1"), + bins: vec![RadrootsOperationalListingBin { + bin_id: bin_id("bin-1"), + quantity: RadrootsCoreQuantity::new(qty_amount, RadrootsCoreUnit::MassG), + price_per_canonical_unit: RadrootsCoreQuantityPrice::new( + RadrootsCoreMoney::new(price_amount, RadrootsCoreCurrency::USD), + RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::MassG), + ), + display_amount: Some(display_qty), + display_unit: Some(RadrootsCoreUnit::MassKg), + display_label: Some("bag".to_string()), + display_price: Some(RadrootsCoreMoney::new( + display_price, + RadrootsCoreCurrency::USD, + )), + display_price_unit: Some(RadrootsCoreUnit::MassKg), + }], + resource_area: None, + plot: None, + discounts: Some(vec![RadrootsCoreDiscount { + scope: RadrootsCoreDiscountScope::Bin, + threshold: RadrootsCoreDiscountThreshold::BinCount { + bin_id: "bin-1".to_string(), + min: 5, + }, + value: RadrootsCoreDiscountValue::MoneyPerBin(RadrootsCoreMoney::new( + RadrootsCoreDecimal::from_str("2").unwrap(), + RadrootsCoreCurrency::USD, + )), + }]), + inventory_available: None, + availability: None, + delivery_method: None, + location: Some(RadrootsOperationalListingPublicLocation { + primary: "Moyobamba".to_string(), + city: Some("Moyobamba".to_string()), + region: Some("San Martin".to_string()), + country: Some("PE".to_string()), + geohash: "9q8yy".to_string(), + }), + images: Some(vec![RadrootsOperationalListingImage { + url: "http://example.com/widget.jpg".to_string(), + size: Some(RadrootsOperationalListingImageSize { w: 1200, h: 800 }), + }]), + } +} + +#[test] +fn operational_listing_build_tags_requires_d_tag() { + assert!(RadrootsDTag::parse("").is_err()); +} + +#[test] +fn operational_listing_build_tags_rejects_invalid_d_tag() { + let listing = sample_listing("invalid:tag"); + let err = operational_listing_build_tags(&listing).unwrap_err(); + assert!(matches!(err, EventEncodeError::InvalidField("d"))); +} + +#[test] +fn listing_roundtrip_from_event() { + let listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg"); + let parts = to_wire_parts(&listing).unwrap(); + + assert_eq!(parts.content, "# Widget"); + + let decoded = operational_listing_from_event(parts.kind, &parts.tags, &parts.content).unwrap(); + assert_eq!(decoded.d_tag, listing.d_tag); + assert_eq!(decoded.product.key, listing.product.key); + assert_eq!(decoded.product.title, listing.product.title); + assert_eq!(decoded.primary_bin_id, listing.primary_bin_id); + assert_eq!(decoded.bins.len(), listing.bins.len()); +} + +#[test] +fn operational_listing_from_event_reconstructs_from_tags_with_markdown_content() { + let listing = sample_listing_full("FAAAAAAAAAAAAAAAAAAAAA"); + let tags = operational_listing_build_tags(&listing).unwrap(); + + let decoded = + operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "### Markdown listing") + .unwrap(); + assert_eq!(decoded.d_tag, listing.d_tag); + assert_eq!(decoded.product.summary, listing.product.summary); + assert_eq!(decoded.primary_bin_id, listing.primary_bin_id); + assert_eq!( + decoded + .location + .as_ref() + .map(|location| location.primary.as_str()), + Some("Moyobamba") + ); +} + +#[test] +fn operational_listing_from_event_rejects_invalid_d_tag() { + let mut tags = + operational_listing_build_tags(&sample_listing("AAAAAAAAAAAAAAAAAAAAAg")).unwrap(); + let d_tag = tags + .iter_mut() + .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_D)) + .expect("d tag"); + d_tag[1] = "invalid:tag".to_string(); + + let err = + operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap_err(); + assert!(matches!(err, EventParseError::InvalidTag(TAG_D))); +} + +#[test] +fn operational_listing_from_event_rejects_wrong_kind() { + let tags = operational_listing_build_tags(&sample_listing("AAAAAAAAAAAAAAAAAAAAAg")).unwrap(); + + let err = operational_listing_from_event(KIND_POST, &tags, "# Widget").unwrap_err(); + assert!(matches!( + err, + EventParseError::InvalidKind { + expected: "30402", + got: KIND_POST + } + )); +} + +#[test] +fn operational_listing_from_event_covers_reference_tag_error_paths() { + let mut tags = sample_operational_listing_tags(); + remove_tags(&mut tags, TAG_D); + assert_missing_tag(tags, TAG_D); + + let mut tags = sample_operational_listing_tags(); + replace_first_tag(&mut tags, TAG_D, vec![TAG_D]); + assert_invalid_tag(tags, TAG_D); + + let mut tags = sample_operational_listing_tags(); + replace_first_tag(&mut tags, TAG_D, vec![TAG_D, " "]); + assert_invalid_tag(tags, TAG_D); + + let mut tags = sample_operational_listing_tags(); + remove_tags(&mut tags, "a"); + assert_missing_tag(tags, "a"); + + let mut tags = sample_operational_listing_tags(); + replace_first_tag(&mut tags, "a", vec!["a"]); + assert_invalid_tag(tags, "a"); + + let mut tags = sample_operational_listing_tags(); + replace_first_tag(&mut tags, "a", vec!["a", "bad:farm_pubkey:farm"]); + assert_invalid_tag(tags, "a"); + + let mut tags = sample_operational_listing_tags(); + replace_first_tag(&mut tags, "a", vec!["a", "30340"]); + assert_invalid_tag(tags, "a"); + + let mut tags = sample_operational_listing_tags(); + replace_first_tag(&mut tags, "a", vec!["a", "30340::farm"]); + assert_invalid_tag(tags, "a"); + + let mut tags = sample_operational_listing_tags(); + replace_first_tag(&mut tags, "a", vec!["a", "30340:farm_pubkey:"]); + assert_invalid_tag(tags, "a"); + + let mut tags = sample_operational_listing_tags(); + replace_first_tag(&mut tags, "a", vec!["a", "30340:farm_pubkey:bad d"]); + assert_invalid_tag(tags, "a"); + + let mut tags = sample_operational_listing_tags(); + replace_first_tag(&mut tags, "a", vec!["a", "30023:other:article"]); + assert_missing_tag(tags, "a"); + + let mut tags = sample_operational_listing_tags(); + remove_tags(&mut tags, "p"); + assert_missing_tag(tags, "p"); + + let mut tags = sample_operational_listing_tags(); + replace_first_tag(&mut tags, "p", vec!["p"]); + assert_invalid_tag(tags, "p"); + + let mut tags = sample_operational_listing_tags(); + replace_first_tag(&mut tags, "p", vec!["p", " "]); + assert_invalid_tag(tags, "p"); + + let mut tags = sample_operational_listing_tags(); + replace_first_tag(&mut tags, "p", vec!["p", "other_pubkey"]); + assert_invalid_tag(tags, "p"); +} + +#[test] +fn operational_listing_from_event_covers_resource_and_plot_reference_paths() { + let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAw"); + listing.resource_area = Some(RadrootsResourceAreaRef { + pubkey: "resource_pubkey".to_string(), + d_tag: "AAAAAAAAAAAAAAAAAAAABQ".to_string(), + }); + listing.plot = Some(RadrootsPlotRef { + pubkey: "plot_pubkey".to_string(), + d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_string(), + }); + let tags = operational_listing_build_tags(&listing).unwrap(); + let decoded = + operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap(); + assert_eq!( + decoded + .resource_area + .as_ref() + .map(|area| area.d_tag.as_str()), + Some("AAAAAAAAAAAAAAAAAAAABQ") + ); + assert_eq!( + decoded.plot.as_ref().map(|plot| plot.d_tag.as_str()), + Some("AAAAAAAAAAAAAAAAAAAAAw") + ); + + let mut tags = sample_operational_listing_tags(); + tags.push(vec!["radroots:resource_area".to_string()]); + assert_invalid_tag(tags, "radroots:resource_area"); + + let mut tags = sample_operational_listing_tags(); + tags.push(vec![ + "radroots:resource_area".to_string(), + format!("{KIND_FARM}:resource_pubkey:resource-area-1"), + ]); + assert_invalid_tag(tags, "radroots:resource_area"); + + let mut tags = sample_operational_listing_tags(); + tags.push(vec![ + "radroots:resource_area".to_string(), + format!("{KIND_RESOURCE_AREA}::resource-area-1"), + ]); + assert_invalid_tag(tags, "radroots:resource_area"); + + let mut tags = sample_operational_listing_tags(); + tags.push(vec![ + "radroots:resource_area".to_string(), + format!("{KIND_RESOURCE_AREA}:resource_pubkey:"), + ]); + assert_invalid_tag(tags, "radroots:resource_area"); + + let mut tags = sample_operational_listing_tags(); + tags.push(vec![ + "radroots:resource_area".to_string(), + format!("{KIND_RESOURCE_AREA}:resource_pubkey:bad d"), + ]); + assert_invalid_tag(tags, "radroots:resource_area"); + + let mut tags = sample_operational_listing_tags(); + tags.push(vec!["radroots:plot".to_string()]); + assert_invalid_tag(tags, "radroots:plot"); + + let mut tags = sample_operational_listing_tags(); + tags.push(vec![ + "radroots:plot".to_string(), + format!("{KIND_RESOURCE_AREA}:plot_pubkey:plot-1"), + ]); + assert_invalid_tag(tags, "radroots:plot"); + + let mut tags = sample_operational_listing_tags(); + tags.push(vec![ + "radroots:plot".to_string(), + format!("{KIND_PLOT}:plot_pubkey:"), + ]); + assert_invalid_tag(tags, "radroots:plot"); + + let mut tags = sample_operational_listing_tags(); + tags.push(vec![ + "radroots:plot".to_string(), + format!("{KIND_PLOT}:plot_pubkey:bad d"), + ]); + assert_invalid_tag(tags, "radroots:plot"); +} + +#[test] +fn operational_listing_from_event_covers_bin_and_price_error_paths() { + let mut tags = sample_operational_listing_tags(); + remove_tags(&mut tags, "radroots:primary_bin"); + assert_missing_tag(tags, "radroots:primary_bin"); + + let mut tags = sample_operational_listing_tags(); + tags.push(vec![ + "radroots:primary_bin".to_string(), + "bin-1".to_string(), + ]); + let decoded = + operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap(); + assert_eq!(decoded.primary_bin_id.as_str(), "bin-1"); + + let mut tags = sample_operational_listing_tags(); + tags.push(vec![ + "radroots:primary_bin".to_string(), + "bin-2".to_string(), + ]); + assert_invalid_tag(tags, "radroots:primary_bin"); + + let mut tags = sample_operational_listing_tags(); + replace_first_tag( + &mut tags, + "radroots:primary_bin", + vec!["radroots:primary_bin", "bin-2"], + ); + assert_invalid_tag(tags, "radroots:primary_bin"); + + let mut tags = sample_operational_listing_tags(); + remove_tags(&mut tags, "radroots:bin"); + assert_missing_tag(tags, "radroots:bin"); + + let mut tags = sample_operational_listing_tags(); + remove_tags(&mut tags, "radroots:price"); + assert_missing_tag(tags, "radroots:price"); + + let mut tags = sample_operational_listing_tags(); + replace_first_tag(&mut tags, "radroots:bin", vec!["radroots:bin"]); + assert_invalid_tag(tags, "radroots:bin"); + + let mut tags = sample_operational_listing_tags(); + replace_first_tag( + &mut tags, + "radroots:bin", + vec!["radroots:bin", "bin-1", "1", "kg"], + ); + assert_invalid_tag(tags, "radroots:bin"); + + let mut tags = sample_operational_listing_tags(); + replace_first_tag( + &mut tags, + "radroots:bin", + vec!["radroots:bin", "bin-1", "1", "not-a-unit"], + ); + assert_invalid_tag(tags, "radroots:bin"); + + let mut tags = sample_operational_listing_tags(); + replace_first_tag( + &mut tags, + "radroots:bin", + vec!["radroots:bin", "bin-1", "1", "each", "1"], + ); + assert_invalid_tag(tags, "radroots:bin"); + + let mut tags = sample_operational_listing_tags(); + replace_first_tag( + &mut tags, + "radroots:bin", + vec![ + "radroots:bin", + "bin-1", + "1", + "each", + "1", + "each", + "label", + "extra", + ], + ); + assert_invalid_tag(tags, "radroots:bin"); + + let mut tags = sample_operational_listing_tags(); + replace_first_tag( + &mut tags, + "radroots:bin", + vec!["radroots:bin", "bin-1", "1", "each", "1", "each"], + ); + let decoded = + operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap(); + assert_eq!( + decoded.bins[0].display_amount, + Some(RadrootsCoreDecimal::from(1u32)) + ); + assert_eq!(decoded.bins[0].display_unit, Some(RadrootsCoreUnit::Each)); + assert_eq!(decoded.bins[0].display_label, None); + + let mut tags = sample_operational_listing_tags(); + tags.push(vec![ + "radroots:bin".to_string(), + "bin-1".to_string(), + "1".to_string(), + "each".to_string(), + ]); + assert_invalid_tag(tags, "radroots:bin"); + + let mut tags = sample_operational_listing_tags(); + replace_first_tag(&mut tags, "radroots:price", vec!["radroots:price"]); + assert_invalid_tag(tags, "radroots:price"); + + let mut tags = sample_operational_listing_tags(); + replace_first_tag( + &mut tags, + "radroots:price", + vec!["radroots:price", "bin-1", "10", "USD", "1", "kg"], + ); + assert_invalid_tag(tags, "radroots:price"); + + let mut tags = sample_operational_listing_tags(); + replace_first_tag( + &mut tags, + "radroots:price", + vec!["radroots:price", "bin-1", "10", "not-currency", "1", "each"], + ); + assert_invalid_tag(tags, "radroots:price"); + + let mut tags = sample_operational_listing_tags(); + replace_first_tag( + &mut tags, + "radroots:price", + vec!["radroots:price", "bin-1", "10", "USD", "1", "each", "10"], + ); + assert_invalid_tag(tags, "radroots:price"); + + let mut tags = sample_operational_listing_tags(); + replace_first_tag( + &mut tags, + "radroots:price", + vec![ + "radroots:price", + "bin-1", + "10", + "USD", + "1", + "each", + "10", + "each", + "extra", + ], + ); + assert_invalid_tag(tags, "radroots:price"); + + let mut tags = sample_operational_listing_tags(); + tags.push(vec![ + "radroots:price".to_string(), + "bin-1".to_string(), + "10".to_string(), + "USD".to_string(), + "1".to_string(), + "each".to_string(), + ]); + assert_invalid_tag(tags, "radroots:price"); + + let mut tags = sample_operational_listing_tags(); + replace_first_tag( + &mut tags, + "radroots:price", + vec!["radroots:price", "bin-1", "10", "USD", "1", "g"], + ); + assert_invalid_tag(tags, "radroots:price"); +} + +#[test] +fn operational_listing_from_event_covers_trade_location_delivery_and_image_paths() { + for expected in ["dd", "dd.lat", "dd.lon", "l", "L"] { + let mut tags = sample_operational_listing_tags(); + tags.push(vec![expected.to_string(), "synthetic".to_string()]); + assert_invalid_tag(tags, expected); + } + + let mut tags = sample_operational_listing_tags(); + tags.push(vec!["location".to_string(), "Farm shelf".to_string()]); + let decoded = + operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap(); + assert_eq!(decoded.product.location.as_deref(), Some("Farm shelf")); + assert!(decoded.location.is_none()); + + let mut tags = sample_operational_listing_tags(); + tags.push(vec!["location".to_string(), "Farm shelf".to_string()]); + tags.push(vec![ + "location".to_string(), + "Peru".to_string(), + "Moyobamba".to_string(), + "San Martin".to_string(), + "PE".to_string(), + ]); + tags.push(vec!["g".to_string(), "6gkzw".to_string()]); + let decoded = + operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap(); + assert_eq!(decoded.product.location.as_deref(), Some("Farm shelf")); + assert_eq!( + decoded.location.as_ref().map(|location| { + ( + location.primary.as_str(), + location.city.as_deref(), + location.geohash.as_str(), + ) + }), + Some(("Peru", Some("Moyobamba"), "6gkzw")) + ); + + let mut tags = sample_operational_listing_tags(); + tags.push(vec![ + "location".to_string(), + " ".to_string(), + "Moyobamba".to_string(), + ]); + assert_invalid_tag(tags, "location"); + + let mut tags = sample_operational_listing_tags(); + tags.push(vec![ + "location".to_string(), + "Farm stand".to_string(), + " ".to_string(), + "null".to_string(), + " ".to_string(), + ]); + tags.push(vec!["g".to_string(), "9q8yy".to_string()]); + let decoded = + operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap(); + assert_eq!( + decoded.location.as_ref().map(|location| { + ( + location.primary.as_str(), + location.city.as_deref(), + location.region.as_deref(), + location.country.as_deref(), + location.geohash.as_str(), + ) + }), + Some(("Farm stand", None, None, None, "9q8yy")) + ); + + let mut tags = sample_operational_listing_tags(); + tags.push(vec!["g".to_string(), "9q8ya".to_string()]); + assert_invalid_tag(tags, "g"); + + let mut tags = sample_operational_listing_tags(); + tags.push(vec!["g".to_string(), "9q8yy".to_string()]); + tags.push(vec!["g".to_string(), "6gkzw".to_string()]); + assert_invalid_tag(tags, "g"); + + let mut tags = sample_operational_listing_tags(); + tags.push(vec!["inventory".to_string()]); + assert_invalid_tag(tags, "inventory"); + + let mut tags = sample_operational_listing_tags(); + tags.push(vec!["inventory".to_string(), "bad".to_string()]); + assert_invalid_tag(tags, "inventory"); + + let mut tags = sample_operational_listing_tags(); + tags.push(vec!["inventory".to_string(), "12.5".to_string()]); + tags.push(vec![ + "radroots:availability_start".to_string(), + "1730".to_string(), + ]); + tags.push(vec!["expires_at".to_string(), "1740".to_string()]); + tags.push(vec!["delivery".to_string(), "pickup".to_string()]); + tags.push(vec!["image".to_string(), " ".to_string()]); + tags.push(vec![ + "image".to_string(), + "https://example.test/a.jpg".to_string(), + ]); + tags.push(vec![ + "image".to_string(), + "https://example.test/b.jpg".to_string(), + "bad-size".to_string(), + ]); + let decoded = + operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap(); + let Some(RadrootsOperationalListingAvailability::Window { start, end }) = decoded.availability + else { + panic!("expected availability window"); + }; + assert_eq!(start, Some(1730)); + assert_eq!(end, Some(1740)); + assert!(matches!( + decoded.delivery_method, + Some(RadrootsOperationalListingDeliveryMethod::Pickup) + )); + assert_eq!(decoded.images.as_ref().map(Vec::len), Some(2)); + assert!(decoded.images.as_ref().unwrap()[1].size.is_none()); + + let mut tags = sample_operational_listing_tags(); + tags.push(vec!["delivery".to_string(), "local_delivery".to_string()]); + let decoded = + operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap(); + assert!(matches!( + decoded.delivery_method, + Some(RadrootsOperationalListingDeliveryMethod::LocalDelivery) + )); + + let mut tags = sample_operational_listing_tags(); + tags.push(vec!["delivery".to_string(), "shipping".to_string()]); + let decoded = + operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap(); + assert!(matches!( + decoded.delivery_method, + Some(RadrootsOperationalListingDeliveryMethod::Shipping) + )); + + let mut tags = sample_operational_listing_tags(); + tags.push(vec![ + "delivery".to_string(), + "other".to_string(), + "bike courier".to_string(), + ]); + let decoded = + operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap(); + let Some(RadrootsOperationalListingDeliveryMethod::Other { method }) = decoded.delivery_method + else { + panic!("expected other delivery method"); + }; + assert_eq!(method, "bike courier"); + + let mut tags = sample_operational_listing_tags(); + tags.push(vec!["delivery".to_string(), "drone".to_string()]); + let decoded = + operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap(); + let Some(RadrootsOperationalListingDeliveryMethod::Other { method }) = decoded.delivery_method + else { + panic!("expected fallback delivery method"); + }; + assert_eq!(method, "drone"); + + let mut tags = sample_operational_listing_tags(); + tags.push(vec!["status".to_string(), "active".to_string()]); + let decoded = + operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap(); + assert!(matches!( + decoded.availability, + Some(RadrootsOperationalListingAvailability::Status { + status: RadrootsOperationalListingStatus::Active + }) + )); + + let mut tags = sample_operational_listing_tags(); + tags.push(vec!["status".to_string(), "sold".to_string()]); + let decoded = + operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap(); + assert!(matches!( + decoded.availability, + Some(RadrootsOperationalListingAvailability::Status { + status: RadrootsOperationalListingStatus::Sold + }) + )); + + let mut tags = sample_operational_listing_tags(); + tags.push(vec!["status".to_string(), "paused".to_string()]); + let decoded = + operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap(); + let Some(RadrootsOperationalListingAvailability::Status { + status: RadrootsOperationalListingStatus::Other { value }, + }) = decoded.availability + else { + panic!("expected other availability status"); + }; + assert_eq!(value, "paused"); +} + +#[test] +fn operational_listing_from_event_rejects_private_location_content_edges() { + let tags = sample_operational_listing_tags(); + for content in [ + "# Widget", + "{not-json", + r#"{"name":"Widget"}"#, + r#"{"location":{"public_label":"Farm shelf"}}"#, + ] { + let decoded = + operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, content).unwrap(); + assert_eq!(decoded.product.key, "sku"); + } + + for key in [ + "lat", + "lng", + "lon", + "point", + "polygon", + "coordinates", + "accuracy", + "altitude", + "label", + "tag_0", + "gcs", + ] { + let content = format!(r#"{{"location":{{"{key}":"secret"}}}}"#); + let err = + operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, &content).unwrap_err(); + assert!(matches!(err, EventParseError::InvalidJson("content"))); + } +} + +#[test] +fn operational_listing_from_event_covers_remaining_edge_paths() { + let mut tags = sample_operational_listing_tags(); + tags.insert(0, Vec::new()); + tags.push(vec!["location".to_string()]); + let decoded = + operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap(); + assert_eq!(decoded.product.location, None); + + let mut tags = sample_operational_listing_tags(); + tags.push(vec![ + "radroots:plot".to_string(), + format!("{KIND_PLOT}::AAAAAAAAAAAAAAAAAAAAAw"), + ]); + assert_invalid_tag(tags, "radroots:plot"); + + let mut tags = sample_operational_listing_tags(); + tags.push(vec![ + "radroots:primary_bin".to_string(), + "bin-2".to_string(), + ]); + assert_invalid_tag(tags, "radroots:primary_bin"); + + let mut tags = sample_operational_listing_tags(); + let primary_position = tags + .iter() + .position(|tag| tag.first().map(String::as_str) == Some("radroots:primary_bin")) + .expect("primary bin tag"); + tags.insert( + primary_position + 1, + vec!["radroots:primary_bin".to_string(), "bin-2".to_string()], + ); + assert_invalid_tag(tags, "radroots:primary_bin"); + + let mut tags = sample_operational_listing_tags(); + tags.insert(0, vec!["key".to_string(), " ".to_string()]); + tags.push(vec!["key".to_string(), "ignored".to_string()]); + tags.insert(0, vec!["summary".to_string(), " ".to_string()]); + tags.push(vec!["summary".to_string(), "first summary".to_string()]); + tags.push(vec!["summary".to_string(), "ignored summary".to_string()]); + tags.push(vec!["process".to_string(), "null".to_string()]); + tags.push(vec!["lot".to_string(), " null ".to_string()]); + tags.push(vec!["profile".to_string(), "null".to_string()]); + tags.push(vec!["year".to_string(), "null".to_string()]); + let decoded = + operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap(); + assert_eq!(decoded.product.key, "sku"); + assert_eq!(decoded.product.summary.as_deref(), Some("first summary")); + assert_eq!(decoded.product.process, None); + assert_eq!(decoded.product.lot, None); + assert_eq!(decoded.product.profile, None); + assert_eq!(decoded.product.year, None); + + let mut tags = sample_operational_listing_tags(); + tags.push(vec!["radroots:availability_start".to_string()]); + assert_invalid_tag(tags, "radroots:availability_start"); + + let mut tags = sample_operational_listing_tags(); + tags.push(vec![ + "radroots:availability_start".to_string(), + "bad".to_string(), + ]); + assert_invalid_tag(tags, "radroots:availability_start"); +} + +#[test] +fn listing_parsed_wrappers_preserve_event_metadata() { + let listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAQ"); + let parts = to_wire_parts(&listing).unwrap(); + let data = data_from_event( + EVENT_ID.to_string(), + AUTHOR.to_string(), + 7, + parts.kind, + parts.content.clone(), + parts.tags.clone(), + ) + .unwrap(); + assert_eq!(data.id, EVENT_ID); + assert_eq!(data.author, AUTHOR); + assert_eq!(data.published_at, 7); + assert_eq!(data.kind, KIND_CLASSIFIED_LISTING); + assert_eq!(data.data.d_tag, listing.d_tag); + + let parsed = parsed_from_event( + EVENT_ID.to_string(), + AUTHOR.to_string(), + 7, + parts.kind, + parts.content.clone(), + parts.tags.clone(), + EVENT_SIG.to_string(), + ) + .unwrap(); + assert_eq!(parsed.event.id_str(), EVENT_ID); + assert_eq!(parsed.event.author_str(), AUTHOR); + assert_eq!(parsed.event.created_at_u64(), 7); + assert_eq!(parsed.event.sig_str(), EVENT_SIG); + assert_eq!(parsed.data.data.d_tag, listing.d_tag); + + let event = event_envelope(parts.kind, parts.tags, parts.content); + let data = data_from_nostr_event(&event).unwrap(); + assert_eq!(data.data.d_tag, listing.d_tag); + let parsed = parsed_from_nostr_event(&event).unwrap(); + assert_eq!(parsed.event.sig_str(), EVENT_SIG); + assert_eq!(parsed.data.data.d_tag, listing.d_tag); + + let err = parsed_from_event( + EVENT_ID.to_string(), + AUTHOR.to_string(), + 7, + KIND_POST, + event.content().to_string(), + event.tags_as_vec(), + EVENT_SIG.to_string(), + ) + .unwrap_err(); + assert!(matches!( + err, + EventParseError::InvalidKind { + expected: "30402", + got: KIND_POST + } + )); +} + +#[test] +fn retired_listing_kind_is_rejected() { + let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAQ"); + listing.published_at = Some(1_781_895_600); + + assert!(matches!( + to_wire_parts_with_kind(&listing, 30403), + Err(EventEncodeError::InvalidKind(30403)) + )); +} + +#[test] +fn listing_roundtrips_published_at_for_active_and_rejects_bad_value() { + let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg"); + listing.published_at = Some(1_781_895_600); + let parts = to_wire_parts_with_kind(&listing, KIND_CLASSIFIED_LISTING).unwrap(); + assert!(parts.tags.iter().any(|tag| { + tag.first().map(|value| value.as_str()) == Some(TAG_PUBLISHED_AT) + && tag.get(1).map(|value| value.as_str()) == Some("1781895600") + })); + + let decoded = operational_listing_from_event(parts.kind, &parts.tags, &parts.content).unwrap(); + assert_eq!(decoded.published_at, Some(1_781_895_600)); + + let mut tags = parts.tags; + let published_at = tags + .iter_mut() + .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_PUBLISHED_AT)) + .expect("published_at tag"); + published_at[1] = "bad".to_string(); + let err = + operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap_err(); + assert!(matches!(err, EventParseError::InvalidTag(TAG_PUBLISHED_AT))); +} + +#[test] +fn to_wire_parts_rejects_non_listing_kind() { + let err = + to_wire_parts_with_kind(&sample_listing("AAAAAAAAAAAAAAAAAAAAAg"), KIND_POST).unwrap_err(); + assert!(matches!(err, EventEncodeError::InvalidKind(KIND_POST))); +} + +#[test] +fn operational_listing_build_tags_includes_listing_fields() { + let listing = sample_listing_full("AAAAAAAAAAAAAAAAAAAAAg"); + let tags = operational_listing_build_tags(&listing).unwrap(); + + assert!(tags.iter().any(|t| { + t.first().map(|s| s.as_str()) == Some(TAG_D) + && t.get(1).map(|s| s.as_str()) == Some("AAAAAAAAAAAAAAAAAAAAAg") + })); + assert!(tags.iter().any(|t| { + t.first().map(|s| s.as_str()) == Some("p") + && t.get(1).map(|s| s.as_str()) == Some("farm_pubkey") + })); + assert!(tags.iter().any(|t| { + t.first().map(|s| s.as_str()) == Some("a") + && t.get(1).map(|s| s.as_str()) == Some("30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA") + })); + assert!(tags.iter().any(|t| { + t.first().map(|s| s.as_str()) == Some("key") && t.get(1).map(|s| s.as_str()) == Some("sku") + })); + assert!(tags.iter().any(|t| { + t.first().map(|s| s.as_str()) == Some("title") + && t.get(1).map(|s| s.as_str()) == Some("Widget") + })); + + let primary_tag = tags + .iter() + .find(|t| t.first().map(|s| s.as_str()) == Some("radroots:primary_bin")) + .expect("primary bin tag"); + assert_eq!(primary_tag.get(1).map(|s| s.as_str()), Some("bin-1")); + + let bin_tag = tags + .iter() + .find(|t| t.first().map(|s| s.as_str()) == Some("radroots:bin")) + .expect("bin tag"); + assert_eq!(bin_tag.get(1).map(|s| s.as_str()), Some("bin-1")); + assert_eq!(bin_tag.get(2).map(|s| s.as_str()), Some("1000")); + assert_eq!(bin_tag.get(3).map(|s| s.as_str()), Some("g")); + assert_eq!(bin_tag.get(4).map(|s| s.as_str()), Some("1")); + assert_eq!(bin_tag.get(5).map(|s| s.as_str()), Some("kg")); + assert_eq!(bin_tag.get(6).map(|s| s.as_str()), Some("bag")); + + let price_tag = tags + .iter() + .find(|t| t.first().map(|s| s.as_str()) == Some("radroots:price")) + .expect("radroots price tag"); + assert_eq!(price_tag.get(1).map(|s| s.as_str()), Some("bin-1")); + assert_eq!(price_tag.get(2).map(|s| s.as_str()), Some("0.01")); + assert_eq!(price_tag.get(3).map(|s| s.as_str()), Some("USD")); + assert_eq!(price_tag.get(4).map(|s| s.as_str()), Some("1")); + assert_eq!(price_tag.get(5).map(|s| s.as_str()), Some("g")); + assert_eq!(price_tag.get(6).map(|s| s.as_str()), Some("10")); + assert_eq!(price_tag.get(7).map(|s| s.as_str()), Some("kg")); + + let generic_price_tag = tags + .iter() + .find(|t| { + t.first().map(|s| s.as_str()) == Some("price") + && t.get(1).map(|s| s.as_str()) == Some("10") + }) + .expect("generic price tag"); + assert_eq!(generic_price_tag.get(2).map(|s| s.as_str()), Some("USD")); + + let discount_tag = tags + .iter() + .find(|t| t.first().map(|s| s.as_str()) == Some("radroots:discount")) + .expect("discount tag"); + assert!( + discount_tag + .get(1) + .map(|s| s.contains("\"scope\":\"bin\"")) + .unwrap_or(false) + ); + + assert!(tags.iter().any(|t| { + t.first().map(|s| s.as_str()) == Some("location") + && t.get(1).map(|s| s.as_str()) == Some("Moyobamba") + })); + + let g_tags: Vec<&Vec<String>> = tags + .iter() + .filter(|t| t.first().map(|s| s.as_str()) == Some("g")) + .collect(); + assert_eq!(g_tags.len(), 1); + assert_eq!(g_tags[0][1].len(), 5); + assert!( + !tags + .iter() + .any(|t| t.first().map(|s| s.as_str()) == Some("L")) + ); + assert!( + !tags + .iter() + .any(|t| t.first().map(|s| s.as_str()) == Some("l")) + ); + + assert!(tags.iter().any(|t| { + t.first().map(|s| s.as_str()) == Some("image") + && t.get(1).map(|s| s.as_str()) == Some("http://example.com/widget.jpg") + && t.get(2).map(|s| s.as_str()) == Some("1200x800") + })); +} + +#[test] +fn operational_listing_tags_full_uses_single_generic_price_for_primary_bin() { + let mut listing = sample_listing_full("AAAAAAAAAAAAAAAAAAAAAw"); + listing.bins.push(RadrootsOperationalListingBin { + bin_id: bin_id("bin-2"), + quantity: RadrootsCoreQuantity::new( + RadrootsCoreDecimal::from_str("500").unwrap(), + RadrootsCoreUnit::MassG, + ), + price_per_canonical_unit: RadrootsCoreQuantityPrice::new( + RadrootsCoreMoney::new( + RadrootsCoreDecimal::from_str("0.02").unwrap(), + RadrootsCoreCurrency::USD, + ), + RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::MassG), + ), + display_amount: Some(RadrootsCoreDecimal::from(500u32)), + display_unit: Some(RadrootsCoreUnit::MassG), + display_label: Some("sample".to_string()), + display_price: Some(RadrootsCoreMoney::new( + RadrootsCoreDecimal::from_str("10").unwrap(), + RadrootsCoreCurrency::USD, + )), + display_price_unit: Some(RadrootsCoreUnit::MassG), + }); + + let tags = operational_listing_tags_full(&listing).unwrap(); + let generic_price_tags: Vec<&Vec<String>> = tags + .iter() + .filter(|tag| tag.first().map(|value| value.as_str()) == Some("price")) + .collect(); + assert_eq!(generic_price_tags.len(), 1); + assert_eq!( + generic_price_tags[0].get(1).map(|value| value.as_str()), + Some("10") + ); + assert_eq!( + generic_price_tags[0].get(2).map(|value| value.as_str()), + Some("USD") + ); +} + +#[test] +fn operational_listing_tags_full_includes_trade_fields() { + let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg"); + let inventory = RadrootsCoreDecimal::from_str("12.5").unwrap(); + let inventory_value = inventory.to_string(); + listing.inventory_available = Some(inventory); + listing.availability = Some(RadrootsOperationalListingAvailability::Window { + start: Some(1730000000), + end: Some(1731000000), + }); + listing.delivery_method = Some(RadrootsOperationalListingDeliveryMethod::Shipping); + + let tags = operational_listing_tags_full(&listing).unwrap(); + + assert!(tags.iter().any(|t| { + t.first().map(|s| s.as_str()) == Some("inventory") + && t.get(1).map(|s| s.as_str()) == Some(inventory_value.as_str()) + })); + assert!(tags.iter().any(|t| { + t.first().map(|s| s.as_str()) == Some("radroots:availability_start") + && t.get(1).map(|s| s.as_str()) == Some("1730000000") + })); + assert!(tags.iter().any(|t| { + t.first().map(|s| s.as_str()) == Some("expires_at") + && t.get(1).map(|s| s.as_str()) == Some("1731000000") + })); + assert!(tags.iter().any(|t| { + t.first().map(|s| s.as_str()) == Some("delivery") + && t.get(1).map(|s| s.as_str()) == Some("shipping") + })); +} + +#[test] +fn operational_listing_tags_full_includes_status_tag() { + let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg"); + listing.availability = Some(RadrootsOperationalListingAvailability::Status { + status: RadrootsOperationalListingStatus::Active, + }); + + let tags = operational_listing_tags_full(&listing).unwrap(); + + assert!(tags.iter().any(|t| { + t.first().map(|s| s.as_str()) == Some("status") + && t.get(1).map(|s| s.as_str()) == Some("active") + })); +} + +#[test] +fn operational_listing_build_tags_ignores_null_strings() { + let mut listing = sample_listing_full("AAAAAAAAAAAAAAAAAAAAAg"); + listing.product.summary = Some("null".to_string()); + listing.product.process = Some("null".to_string()); + listing.product.lot = Some("null".to_string()); + listing.product.location = Some("null".to_string()); + listing.product.profile = Some("null".to_string()); + listing.product.year = Some("null".to_string()); + listing.location = Some(RadrootsOperationalListingPublicLocation { + primary: "Moyobamba".to_string(), + city: Some("null".to_string()), + region: Some("San Martin".to_string()), + country: Some("null".to_string()), + geohash: "9q8yy".to_string(), + }); + listing.images = Some(vec![RadrootsOperationalListingImage { + url: "null".to_string(), + size: None, + }]); + + let tags = operational_listing_build_tags(&listing).unwrap(); + assert!( + !tags + .iter() + .any(|tag| tag.iter().any(|value| value == "null")) + ); +} + +#[test] +fn operational_listing_build_tags_rejects_location_without_public_locality() { + let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg"); + listing.location = Some(RadrootsOperationalListingPublicLocation { + primary: "Farm stand".to_string(), + city: Some("null".to_string()), + region: None, + country: None, + geohash: "9q8yy".to_string(), + }); + + assert!(matches!( + operational_listing_build_tags(&listing), + Err(EventEncodeError::EmptyRequiredField("location.locality")) + )); +} + +#[test] +fn operational_listing_tags_with_options_cover_location_fallback_paths() { + let mut geohash_only = sample_listing("AAAAAAAAAAAAAAAAAAAAAg"); + geohash_only.location = Some(RadrootsOperationalListingPublicLocation { + primary: "Moyobamba".to_string(), + city: Some("Moyobamba".to_string()), + region: None, + country: None, + geohash: "6gkzw".to_string(), + }); + let tags = operational_listing_tags_with_options( + &geohash_only, + OperationalListingTagOptions::default(), + ) + .unwrap(); + assert!( + tags.iter() + .any(|tag| tag.first().map(|value| value.as_str()) == Some("g")) + ); + assert!( + !tags + .iter() + .any(|tag| tag.first().map(|value| value.as_str()) == Some("l")) + ); + + let mut no_coordinates = sample_listing("AAAAAAAAAAAAAAAAAAAAAQ"); + no_coordinates.location = Some(RadrootsOperationalListingPublicLocation { + primary: "Moyobamba".to_string(), + city: Some("Moyobamba".to_string()), + region: None, + country: None, + geohash: "9q8yy".to_string(), + }); + let tags = operational_listing_tags_with_options( + &no_coordinates, + OperationalListingTagOptions::default(), + ) + .unwrap(); + assert!( + !tags + .iter() + .any(|tag| tag.first().map(|value| value.as_str()) == Some("L")) + ); + assert!( + tags.iter() + .any(|tag| tag.first().map(|value| value.as_str()) == Some("g")) + ); + + let mut without_geohash = sample_listing("AAAAAAAAAAAAAAAAAAAAAw"); + without_geohash.location = Some(RadrootsOperationalListingPublicLocation { + primary: "Moyobamba".to_string(), + city: Some("Moyobamba".to_string()), + region: None, + country: None, + geohash: "9q8yy".to_string(), + }); + let tags = operational_listing_tags_with_options( + &without_geohash, + OperationalListingTagOptions { + ..OperationalListingTagOptions::default() + }, + ) + .unwrap(); + assert!( + tags.iter() + .any(|tag| tag.first().map(|value| value.as_str()) == Some("g")) + ); + assert!( + !tags + .iter() + .any(|tag| tag.first().map(|value| value.as_str()) == Some("L")) + ); +} diff --git a/crates/event_codec/tests/social_events.rs b/crates/event_codec/tests/social_events.rs @@ -7,10 +7,10 @@ use radroots_event::{ file_metadata::RadrootsFileMetadata, group::{RadrootsGroupEditableMetadata, RadrootsGroupMetadata}, kinds::{ - KIND_ARTICLE, KIND_FARM, KIND_FARM_CRDT_CHANGE, KIND_GROUP_METADATA, KIND_HTTP_AUTH, - KIND_LISTING, KIND_POST, KIND_PUBLIC_FILE_METADATA, KIND_RELAY_AUTH, KIND_REPORT, - is_home_feed_candidate_kind, is_market_candidate_kind, is_private_farm_ops_kind, - is_public_social_kind, + KIND_ARTICLE, KIND_CLASSIFIED_LISTING, KIND_FARM, KIND_FARM_CRDT_CHANGE, + KIND_GROUP_METADATA, KIND_HTTP_AUTH, KIND_POST, KIND_PUBLIC_FILE_METADATA, KIND_RELAY_AUTH, + KIND_REPORT, is_home_feed_candidate_kind, is_market_candidate_kind, + is_private_farm_ops_kind, is_public_social_kind, }, social::RadrootsSocialMediaDimensions, }; @@ -25,7 +25,7 @@ use radroots_event_codec::{ decode::file_metadata_from_event, encode::to_wire_parts as public_file_to_wire_parts, }, group::{decode::group_metadata_from_event, encode::group_metadata_to_wire_parts}, - listing::decode::listing_from_event, + operational_listing::decode::operational_listing_from_event, post::decode::post_from_event, }; @@ -88,7 +88,7 @@ fn social_events_reject_private_farm_ops_semantics_in_public_codecs() { }) )); assert!(matches!( - listing_from_event(KIND_FARM_CRDT_CHANGE, &[], "farm task"), + operational_listing_from_event(KIND_FARM_CRDT_CHANGE, &[], "farm task"), Err(EventParseError::InvalidKind { expected: "30402", got: KIND_FARM_CRDT_CHANGE @@ -98,8 +98,8 @@ fn social_events_reject_private_farm_ops_semantics_in_public_codecs() { assert!(is_public_social_kind(KIND_ARTICLE)); assert!(is_public_social_kind(KIND_PUBLIC_FILE_METADATA)); assert!(!is_public_social_kind(KIND_FARM_CRDT_CHANGE)); - assert!(!is_public_social_kind(KIND_LISTING)); - assert!(is_home_feed_candidate_kind(KIND_LISTING)); + assert!(!is_public_social_kind(KIND_CLASSIFIED_LISTING)); + assert!(is_home_feed_candidate_kind(KIND_CLASSIFIED_LISTING)); assert!(is_home_feed_candidate_kind(KIND_FARM)); assert!(is_home_feed_candidate_kind(KIND_PUBLIC_FILE_METADATA)); assert!(!is_home_feed_candidate_kind(30403)); @@ -107,7 +107,7 @@ fn social_events_reject_private_farm_ops_semantics_in_public_codecs() { assert!(!is_home_feed_candidate_kind(KIND_FARM_CRDT_CHANGE)); assert!(!is_home_feed_candidate_kind(KIND_RELAY_AUTH)); assert!(!is_home_feed_candidate_kind(KIND_HTTP_AUTH)); - assert!(is_market_candidate_kind(KIND_LISTING)); + assert!(is_market_candidate_kind(KIND_CLASSIFIED_LISTING)); assert!(!is_market_candidate_kind(30403)); } diff --git a/crates/event_codec/tests/structured_encode_default.rs b/crates/event_codec/tests/structured_encode_default.rs @@ -16,7 +16,9 @@ use radroots_event::kinds::{ KIND_COOP, KIND_DOCUMENT, KIND_FARM, KIND_PLOT, KIND_RESOURCE_AREA, KIND_RESOURCE_HARVEST_CAP, }; use radroots_event::list_set::RadrootsListSet; -use radroots_event::listing::{RadrootsListing, RadrootsListingBin, RadrootsListingProduct}; +use radroots_event::operational_listing::{ + RadrootsOperationalListing, RadrootsOperationalListingBin, RadrootsOperationalListingProduct, +}; use radroots_event::plot::{RadrootsPlot, RadrootsPlotLocation, RadrootsPlotRef}; use radroots_event::resource_area::{ RadrootsResourceArea, RadrootsResourceAreaLocation, RadrootsResourceAreaRef, @@ -40,9 +42,9 @@ use radroots_event_codec::farm::encode::{ to_wire_parts_with_kind as farm_to_wire_parts_with_kind, }; use radroots_event_codec::farm::list_sets::{ - farm_listings_list_set, farm_listings_list_set_from_listings, farm_members_list_set, - farm_owners_list_set, farm_plots_list_set, farm_plots_list_set_from_plots, - farm_workers_list_set, member_of_farms_list_set, + farm_members_list_set, farm_operational_listings_list_set, + farm_operational_listings_list_set_from_listings, farm_owners_list_set, farm_plots_list_set, + farm_plots_list_set_from_plots, farm_workers_list_set, member_of_farms_list_set, }; use radroots_event_codec::plot::encode::{ plot_address, plot_build_tags, to_wire_parts as plot_to_wire_parts, @@ -107,21 +109,21 @@ fn sample_gcs() -> RadrootsGcsLocation { } } -fn sample_listing(d_tag: &str) -> RadrootsListing { +fn sample_listing(d_tag: &str) -> RadrootsOperationalListing { let quantity = RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::Each); let price = RadrootsCoreQuantityPrice::new( RadrootsCoreMoney::new(RadrootsCoreDecimal::from(10u32), RadrootsCoreCurrency::USD), quantity.clone(), ); - RadrootsListing { + RadrootsOperationalListing { d_tag: listing_d_tag(d_tag), published_at: None, farm: RadrootsFarmRef { pubkey: TEST_PUBKEY_HEX.to_string(), d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), }, - product: RadrootsListingProduct { + product: RadrootsOperationalListingProduct { key: "sku".to_string(), title: "Widget".to_string(), category: "Tools".to_string(), @@ -133,7 +135,7 @@ fn sample_listing(d_tag: &str) -> RadrootsListing { year: None, }, primary_bin_id: bin_id("bin-1"), - bins: vec![RadrootsListingBin { + bins: vec![RadrootsOperationalListingBin { bin_id: bin_id("bin-1"), quantity, price_per_canonical_unit: price, @@ -877,11 +879,12 @@ fn structured_list_sets_cover_success_and_error_paths() { assert_eq!(plots.entries.len(), 1); let listings = - farm_listings_list_set(farm_id, TEST_PUBKEY_HEX, ["AAAAAAAAAAAAAAAAAAAAAg"]).unwrap(); + farm_operational_listings_list_set(farm_id, TEST_PUBKEY_HEX, ["AAAAAAAAAAAAAAAAAAAAAg"]) + .unwrap(); assert_eq!(listings.d_tag, format!("farm:{farm_id}:listings")); assert_eq!(listings.entries.len(), 1); - let listings_from = farm_listings_list_set_from_listings( + let listings_from = farm_operational_listings_list_set_from_listings( farm_id, TEST_PUBKEY_HEX, [sample_listing("AAAAAAAAAAAAAAAAAAAAAg")].iter(), @@ -921,7 +924,7 @@ fn structured_list_sets_cover_success_and_error_paths() { err, EventEncodeError::EmptyRequiredField("entry.values") )); - let err = farm_listings_list_set(farm_id, TEST_PUBKEY_HEX, [" "]).unwrap_err(); + let err = farm_operational_listings_list_set(farm_id, TEST_PUBKEY_HEX, [" "]).unwrap_err(); assert!(matches!( err, EventEncodeError::EmptyRequiredField("listing_id") diff --git a/crates/event_codec/tests/tag_builders.rs b/crates/event_codec/tests/tag_builders.rs @@ -26,13 +26,14 @@ use radroots_event::kinds::{ }; use radroots_event::list::{RadrootsList, RadrootsListEntry}; use radroots_event::list_set::RadrootsListSet; -use radroots_event::listing::{ - RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, RadrootsListingImage, - RadrootsListingImageSize, RadrootsListingProduct, RadrootsListingPublicLocation, - RadrootsListingStatus, -}; use radroots_event::message::{RadrootsMessage, RadrootsMessageRecipient}; use radroots_event::message_file::RadrootsMessageFile; +use radroots_event::operational_listing::{ + RadrootsOperationalListing, RadrootsOperationalListingAvailability, + RadrootsOperationalListingBin, RadrootsOperationalListingImage, + RadrootsOperationalListingImageSize, RadrootsOperationalListingProduct, + RadrootsOperationalListingPublicLocation, RadrootsOperationalListingStatus, +}; use radroots_event::plot::{RadrootsPlot, RadrootsPlotRef}; use radroots_event::reaction::RadrootsReaction; use radroots_event::resource_area::{ @@ -43,8 +44,10 @@ use radroots_event::seal::RadrootsSeal; use radroots_event::social::RadrootsSocialTarget; use radroots_event_codec::error::EventEncodeError; use radroots_event_codec::job::encode::JobEncodeError; -use radroots_event_codec::listing::encode::listing_build_tags; -use radroots_event_codec::listing::tags::{ListingTagOptions, listing_tags_with_options}; +use radroots_event_codec::operational_listing::encode::operational_listing_build_tags; +use radroots_event_codec::operational_listing::tags::{ + OperationalListingTagOptions, operational_listing_tags_with_options, +}; use radroots_event_codec::tag_builders::RadrootsEventTagBuilder; use test_fixtures::{ CDN_PRIMARY_HTTPS, FIXTURE_ALICE_NPUB, FIXTURE_ALICE_PUBLIC_KEY_HEX, RELAY_PRIMARY_WSS, @@ -109,7 +112,7 @@ fn sample_gcs() -> RadrootsGcsLocation { } } -fn sample_listing() -> RadrootsListing { +fn sample_listing() -> RadrootsOperationalListing { let quantity = RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::Each); let price = RadrootsCoreQuantityPrice::new( @@ -117,14 +120,14 @@ fn sample_listing() -> RadrootsListing { quantity.clone(), ); - RadrootsListing { + RadrootsOperationalListing { d_tag: d_tag("AAAAAAAAAAAAAAAAAAAAAg"), published_at: None, farm: RadrootsFarmRef { pubkey: TEST_NPUB.to_string(), d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), }, - product: RadrootsListingProduct { + product: RadrootsOperationalListingProduct { key: "sku".to_string(), title: "Widget".to_string(), category: "Tools".to_string(), @@ -136,7 +139,7 @@ fn sample_listing() -> RadrootsListing { year: None, }, primary_bin_id: bin_id("bin-1"), - bins: vec![RadrootsListingBin { + bins: vec![RadrootsOperationalListingBin { bin_id: bin_id("bin-1"), quantity, price_per_canonical_unit: price, @@ -161,7 +164,7 @@ fn sample_listing() -> RadrootsListing { fn event_tag_builder_impls_build_tags_for_all_supported_types() { let listing = sample_listing(); assert!(!listing.build_tags().unwrap().is_empty()); - assert!(!listing_build_tags(&listing).unwrap().is_empty()); + assert!(!operational_listing_build_tags(&listing).unwrap().is_empty()); let app_data = RadrootsAppData { d_tag: "radroots.app".to_string(), @@ -450,35 +453,39 @@ fn listing_and_message_builders_cover_optional_shapes() { listing.product.location = Some("Moyobamba".to_string()); listing.product.profile = Some("fruity".to_string()); listing.product.year = Some("2024".to_string()); - listing.location = Some(RadrootsListingPublicLocation { + listing.location = Some(RadrootsOperationalListingPublicLocation { primary: "Moyobamba".to_string(), city: Some("Moyobamba".to_string()), region: Some("San Martin".to_string()), country: Some("PE".to_string()), geohash: "9q8yy".to_string(), }); - listing.images = Some(vec![RadrootsListingImage { + listing.images = Some(vec![RadrootsOperationalListingImage { url: cdn_url("a.jpg"), - size: Some(RadrootsListingImageSize { w: 1200, h: 800 }), + size: Some(RadrootsOperationalListingImageSize { w: 1200, h: 800 }), }]); - assert!(!listing_build_tags(&listing).unwrap().is_empty()); + assert!(!operational_listing_build_tags(&listing).unwrap().is_empty()); let mut listing_with_trade = listing.clone(); listing_with_trade.inventory_available = Some(RadrootsCoreDecimal::from(12u32)); - let with_trade_fields: fn() -> ListingTagOptions = ListingTagOptions::with_trade_fields; + let with_trade_fields: fn() -> OperationalListingTagOptions = + OperationalListingTagOptions::with_trade_fields; let trade_options = with_trade_fields(); - listing_with_trade.availability = Some(RadrootsListingAvailability::Status { - status: RadrootsListingStatus::Active, + listing_with_trade.availability = Some(RadrootsOperationalListingAvailability::Status { + status: RadrootsOperationalListingStatus::Active, }); - let listing_tags_full_fn: fn(&RadrootsListing) -> Result<Vec<Vec<String>>, EventEncodeError> = - radroots_event_codec::listing::tags::listing_tags_full; - let full_tags = listing_tags_full_fn(&listing_with_trade).unwrap(); + let operational_listing_tags_full_fn: fn( + &RadrootsOperationalListing, + ) -> Result<Vec<Vec<String>>, EventEncodeError> = + radroots_event_codec::operational_listing::tags::operational_listing_tags_full; + let full_tags = operational_listing_tags_full_fn(&listing_with_trade).unwrap(); assert!(full_tags.iter().any(|tag| { tag.first().map(|v| v.as_str()) == Some("inventory") && tag.get(1).map(|v| v.as_str()) == Some("12") })); - let trade_tags = listing_tags_with_options(&listing_with_trade, trade_options).unwrap(); + let trade_tags = + operational_listing_tags_with_options(&listing_with_trade, trade_options).unwrap(); assert!(trade_tags.iter().any(|tag| { tag.first().map(|v| v.as_str()) == Some("inventory") && tag.get(1).map(|v| v.as_str()) == Some("12") @@ -489,62 +496,70 @@ fn listing_and_message_builders_cover_optional_shapes() { })); let mut listing_status_sold = listing_with_trade.clone(); - listing_status_sold.availability = Some(RadrootsListingAvailability::Status { - status: RadrootsListingStatus::Sold, + listing_status_sold.availability = Some(RadrootsOperationalListingAvailability::Status { + status: RadrootsOperationalListingStatus::Sold, }); - let sold_tags = listing_tags_with_options(&listing_status_sold, trade_options).unwrap(); + let sold_tags = + operational_listing_tags_with_options(&listing_status_sold, trade_options).unwrap(); assert!(sold_tags.iter().any(|tag| { tag.first().map(|v| v.as_str()) == Some("status") && tag.get(1).map(|v| v.as_str()) == Some("sold") })); let mut listing_status_other = listing_with_trade.clone(); - listing_status_other.availability = Some(RadrootsListingAvailability::Status { - status: RadrootsListingStatus::Other { + listing_status_other.availability = Some(RadrootsOperationalListingAvailability::Status { + status: RadrootsOperationalListingStatus::Other { value: "paused".to_string(), }, }); - let other_tags = listing_tags_with_options(&listing_status_other, trade_options).unwrap(); + let other_tags = + operational_listing_tags_with_options(&listing_status_other, trade_options).unwrap(); assert!(other_tags.iter().any(|tag| { tag.first().map(|v| v.as_str()) == Some("status") && tag.get(1).map(|v| v.as_str()) == Some("paused") })); let mut listing_geohash_only = listing_with_trade.clone(); - listing_geohash_only.location = Some(RadrootsListingPublicLocation { + listing_geohash_only.location = Some(RadrootsOperationalListingPublicLocation { primary: "Moyobamba".to_string(), city: Some("Moyobamba".to_string()), region: None, country: None, geohash: "6gkzw".to_string(), }); - let geohash_tags = - listing_tags_with_options(&listing_geohash_only, ListingTagOptions::default()).unwrap(); + let geohash_tags = operational_listing_tags_with_options( + &listing_geohash_only, + OperationalListingTagOptions::default(), + ) + .unwrap(); assert!(geohash_tags.iter().any(|tag| { tag.first().map(|v| v.as_str()) == Some("g") && tag.get(1).map(|v| v.as_str()) == Some("6gkzw") })); let mut listing_no_coordinates = listing_with_trade.clone(); - listing_no_coordinates.location = Some(RadrootsListingPublicLocation { + listing_no_coordinates.location = Some(RadrootsOperationalListingPublicLocation { primary: "Moyobamba".to_string(), city: Some("Moyobamba".to_string()), region: None, country: None, geohash: "9q8yy".to_string(), }); - let no_coordinates_tags = - listing_tags_with_options(&listing_no_coordinates, ListingTagOptions::default()).unwrap(); + let no_coordinates_tags = operational_listing_tags_with_options( + &listing_no_coordinates, + OperationalListingTagOptions::default(), + ) + .unwrap(); assert!( !no_coordinates_tags .iter() .any(|tag| tag.first().map(|v| v.as_str()) == Some("L")) ); - let without_private_location_tags = listing_tags_with_options( + let without_private_location_tags = operational_listing_tags_with_options( &listing_with_trade, - ListingTagOptions { - ..ListingTagOptions::default() + OperationalListingTagOptions { + ..OperationalListingTagOptions::default() }, ) .unwrap(); @@ -555,7 +570,7 @@ fn listing_and_message_builders_cover_optional_shapes() { ); let mut listing_with_empty_primary_location = listing_with_trade.clone(); - listing_with_empty_primary_location.location = Some(RadrootsListingPublicLocation { + listing_with_empty_primary_location.location = Some(RadrootsOperationalListingPublicLocation { primary: " null ".to_string(), city: Some("Moyobamba".to_string()), region: None, @@ -563,7 +578,7 @@ fn listing_and_message_builders_cover_optional_shapes() { geohash: "9q8yy".to_string(), }); assert!(matches!( - listing_tags_with_options(&listing_with_empty_primary_location, trade_options), + operational_listing_tags_with_options(&listing_with_empty_primary_location, trade_options), Err(EventEncodeError::EmptyRequiredField("location.primary")) )); @@ -581,8 +596,9 @@ fn listing_and_message_builders_cover_optional_shapes() { }]); #[cfg(feature = "serde_json")] { - let tags = listing_tags_with_options(&listing_with_discount_payload, trade_options) - .expect("discount serialization works"); + let tags = + operational_listing_tags_with_options(&listing_with_discount_payload, trade_options) + .expect("discount serialization works"); assert!( tags.iter() .any(|tag| tag.first().map(|v| v.as_str()) == Some("radroots:discount")) @@ -590,8 +606,9 @@ fn listing_and_message_builders_cover_optional_shapes() { } #[cfg(not(feature = "serde_json"))] { - let err = listing_tags_with_options(&listing_with_discount_payload, trade_options) - .expect_err("discounts require serde_json in non-serde lane"); + let err = + operational_listing_tags_with_options(&listing_with_discount_payload, trade_options) + .expect_err("discounts require serde_json in non-serde lane"); assert!(matches!(err, EventEncodeError::Json)); } @@ -634,17 +651,17 @@ fn listing_and_message_builders_cover_optional_shapes() { fn listing_builder_rejects_required_field_errors() { let mut listing = sample_listing(); listing.d_tag = d_tag("listing:invalid"); - let err = listing_build_tags(&listing).expect_err("invalid listing d_tag"); + let err = operational_listing_build_tags(&listing).expect_err("invalid listing d_tag"); assert!(matches!(err, EventEncodeError::InvalidField("d"))); let mut listing = sample_listing(); listing.bins.clear(); - let err = listing_build_tags(&listing).expect_err("empty bins"); + let err = operational_listing_build_tags(&listing).expect_err("empty bins"); assert!(matches!(err, EventEncodeError::EmptyRequiredField("bins"))); let mut listing = sample_listing(); listing.farm.pubkey = " ".to_string(); - let err = listing_build_tags(&listing).expect_err("empty farm pubkey"); + let err = operational_listing_build_tags(&listing).expect_err("empty farm pubkey"); assert!(matches!( err, EventEncodeError::EmptyRequiredField("farm.pubkey") @@ -652,7 +669,7 @@ fn listing_builder_rejects_required_field_errors() { let mut listing = sample_listing(); listing.farm.d_tag = " ".to_string(); - let err = listing_build_tags(&listing).expect_err("empty farm d_tag"); + let err = operational_listing_build_tags(&listing).expect_err("empty farm d_tag"); assert!(matches!( err, EventEncodeError::EmptyRequiredField("farm.d_tag") diff --git a/crates/event_store/src/model.rs b/crates/event_store/src/model.rs @@ -479,8 +479,8 @@ pub fn tag_semantic_name(value: RadrootsTagSemantic) -> &'static str { RadrootsTagSemantic::Identifier => "identifier", RadrootsTagSemantic::Image => "image", RadrootsTagSemantic::Kind => "kind", - RadrootsTagSemantic::ListingAddress => "listing_address", - RadrootsTagSemantic::ListingSnapshot => "listing_snapshot", + RadrootsTagSemantic::ClassifiedListingAddress => "listing_address", + RadrootsTagSemantic::OperationalListingSnapshot => "listing_snapshot", RadrootsTagSemantic::ListDescription => "list_description", RadrootsTagSemantic::Location => "location", RadrootsTagSemantic::Participant => "participant", @@ -751,8 +751,14 @@ mod tests { (RadrootsTagSemantic::Identifier, "identifier"), (RadrootsTagSemantic::Image, "image"), (RadrootsTagSemantic::Kind, "kind"), - (RadrootsTagSemantic::ListingAddress, "listing_address"), - (RadrootsTagSemantic::ListingSnapshot, "listing_snapshot"), + ( + RadrootsTagSemantic::ClassifiedListingAddress, + "listing_address", + ), + ( + RadrootsTagSemantic::OperationalListingSnapshot, + "listing_snapshot", + ), (RadrootsTagSemantic::ListDescription, "list_description"), (RadrootsTagSemantic::Location, "location"), (RadrootsTagSemantic::Participant, "participant"), diff --git a/crates/event_store/src/store.rs b/crates/event_store/src/store.rs @@ -1642,8 +1642,8 @@ mod tests { use nostr::EventBuilder; use radroots_event::draft::RadrootsSignedEvent; use radroots_event::event_head::event_head_candidate_for_event; - use radroots_event::ids::{RadrootsAddressableCoordinate, RadrootsInventoryBinId}; - use radroots_event::kinds::{KIND_GEOCHAT, KIND_LISTING, KIND_POST, KIND_PROFILE}; + use radroots_event::ids::{RadrootsClassifiedListingAddress, RadrootsInventoryBinId}; + use radroots_event::kinds::{KIND_CLASSIFIED_LISTING, KIND_GEOCHAT, KIND_POST, KIND_PROFILE}; use radroots_event::trade::{ RADROOTS_TRADE_DECISION_CONTRACT_ID, RADROOTS_TRADE_PROPOSAL_CONTRACT_ID, RADROOTS_TRADE_SCHEMA_VERSION, RadrootsFulfillmentProfileV1, @@ -1726,8 +1726,8 @@ mod tests { farm_id: RadrootsDTag::parse("farm-1").expect("farm id"), lines: vec![RadrootsTradeCandidateLineV1 { line_id: RadrootsDTag::parse("line-1").expect("line id"), - listing_addr: RadrootsAddressableCoordinate::parse(format!( - "{KIND_LISTING}:{}:listing-1", + listing_addr: RadrootsClassifiedListingAddress::parse(format!( + "{KIND_CLASSIFIED_LISTING}:{}:listing-1", FIXTURE_ALICE_PUBLIC_KEY_HEX )) .expect("listing address"), @@ -1941,7 +1941,7 @@ mod tests { serde_json::to_string(&wire).expect("raw json") } - fn listing_tags(d_tag: &str) -> Vec<Vec<String>> { + fn operational_listing_tags(d_tag: &str) -> Vec<Vec<String>> { vec![vec!["d".to_owned(), d_tag.to_owned()]] } @@ -2754,7 +2754,7 @@ mod tests { #[tokio::test] async fn malformed_addressable_heads_are_not_projected() { let store = RadrootsEventStore::open_memory().await.expect("open"); - let event = signed_event(KIND_LISTING, 16, Vec::new(), "{}"); + let event = signed_event(KIND_CLASSIFIED_LISTING, 16, Vec::new(), "{}"); let receipt = store .ingest_event(RadrootsEventIngest::new(event.clone(), 2_270)) @@ -2781,13 +2781,23 @@ mod tests { #[tokio::test] async fn id_mismatch_addressable_raw_json_does_not_update_heads() { let store = RadrootsEventStore::open_memory().await.expect("open"); - let original = signed_event(KIND_LISTING, 17, listing_tags("listing-1"), "{}"); + let original = signed_event( + KIND_CLASSIFIED_LISTING, + 17, + operational_listing_tags("listing-1"), + "{}", + ); let first = store .ingest_event(RadrootsEventIngest::new(original.clone(), 2_300)) .await .expect("first"); let coordinate = head_coordinate_for_event(&original); - let invalid = signed_event(KIND_LISTING, 18, listing_tags("listing-1"), "{}"); + let invalid = signed_event( + KIND_CLASSIFIED_LISTING, + 18, + operational_listing_tags("listing-1"), + "{}", + ); let raw_json = tampered_content_raw_json(&invalid, "{\"tampered\":true}"); let error = RadrootsEventIngest::from_raw_json(raw_json, 2_400).expect_err("id mismatch"); let head = store @@ -2804,16 +2814,21 @@ mod tests { #[tokio::test] async fn signature_invalid_addressable_events_do_not_update_heads() { let store = RadrootsEventStore::open_memory().await.expect("open"); - let original = signed_event(KIND_LISTING, 19, listing_tags("listing-2"), "{}"); + let original = signed_event( + KIND_CLASSIFIED_LISTING, + 19, + operational_listing_tags("listing-2"), + "{}", + ); store .ingest_event(RadrootsEventIngest::new(original.clone(), 2_500)) .await .expect("first"); let coordinate = head_coordinate_for_event(&original); let invalid = tamper_signature(&signed_event( - KIND_LISTING, + KIND_CLASSIFIED_LISTING, 20, - listing_tags("listing-2"), + operational_listing_tags("listing-2"), "{}", )); @@ -2842,16 +2857,21 @@ mod tests { #[tokio::test] async fn duplicate_invalid_addressable_events_do_not_update_heads() { let store = RadrootsEventStore::open_memory().await.expect("open"); - let original = signed_event(KIND_LISTING, 21, listing_tags("listing-3"), "{}"); + let original = signed_event( + KIND_CLASSIFIED_LISTING, + 21, + operational_listing_tags("listing-3"), + "{}", + ); store .ingest_event(RadrootsEventIngest::new(original.clone(), 2_700)) .await .expect("original"); let coordinate = head_coordinate_for_event(&original); let invalid = tamper_signature(&signed_event( - KIND_LISTING, + KIND_CLASSIFIED_LISTING, 22, - listing_tags("listing-3"), + operational_listing_tags("listing-3"), "{}", )); @@ -2886,7 +2906,12 @@ mod tests { #[tokio::test] async fn duplicate_verified_addressable_events_preserve_heads() { let store = RadrootsEventStore::open_memory().await.expect("open"); - let event = signed_event(KIND_LISTING, 23, listing_tags("listing-4"), "{}"); + let event = signed_event( + KIND_CLASSIFIED_LISTING, + 23, + operational_listing_tags("listing-4"), + "{}", + ); let coordinate = head_coordinate_for_event(&event); let first = store diff --git a/crates/outbox/src/store.rs b/crates/outbox/src/store.rs @@ -3251,10 +3251,10 @@ fn u32_from_i64(field: &'static str, value: i64) -> Result<u32, RadrootsOutboxEr mod tests { use super::*; use radroots_event::ids::{ - RadrootsAddressableCoordinate, RadrootsDTag, RadrootsEventId, RadrootsInventoryBinId, + RadrootsClassifiedListingAddress, RadrootsDTag, RadrootsEventId, RadrootsInventoryBinId, RadrootsPublicKey, RadrootsTradeId, }; - use radroots_event::kinds::{KIND_GEOCHAT, KIND_LISTING}; + use radroots_event::kinds::{KIND_CLASSIFIED_LISTING, KIND_GEOCHAT}; use radroots_event::trade::{ RADROOTS_TRADE_PROPOSAL_CONTRACT_ID, RADROOTS_TRADE_SCHEMA_VERSION, RadrootsFulfillmentProfileV1, RadrootsTradeCancellationProfileV1, @@ -3312,8 +3312,8 @@ mod tests { farm_id: RadrootsDTag::parse("farm-1").expect("farm id"), lines: vec![RadrootsTradeCandidateLineV1 { line_id: RadrootsDTag::parse("line-1").expect("line id"), - listing_addr: RadrootsAddressableCoordinate::parse(format!( - "{KIND_LISTING}:{}:listing-1", + listing_addr: RadrootsClassifiedListingAddress::parse(format!( + "{KIND_CLASSIFIED_LISTING}:{}:listing-1", FIXTURE_ALICE_PUBLIC_KEY_HEX )) .expect("listing address"), diff --git a/crates/protocol_contract_v1/src/lib.rs b/crates/protocol_contract_v1/src/lib.rs @@ -293,10 +293,10 @@ pub const PROTOCOL_EVENT_CATALOG_V1: &[ProtocolEventDescriptorV1] = &[ purpose: "immutable one-validator set artifact signed by network authority", }, ProtocolEventDescriptorV1 { - name: "listing", + name: "classified_listing", kind: 30402, event_class: ProtocolEventClassV1::Addressable, - purpose: "public listing aggregate and revision", + purpose: "NIP-99 classified listing", }, ]; @@ -769,6 +769,24 @@ mod tests { } #[test] + fn classified_listing_catalog_entry_is_exact_nip_99_contract() { + let classified_listing = PROTOCOL_EVENT_CATALOG_V1 + .iter() + .find(|event| event.kind == 30402) + .expect("kind 30402 catalog entry"); + + assert_eq!( + *classified_listing, + ProtocolEventDescriptorV1 { + name: "classified_listing", + kind: 30402, + event_class: ProtocolEventClassV1::Addressable, + purpose: "NIP-99 classified listing", + } + ); + } + + #[test] fn validation_reports_transport_catalog_errors() { let local = TRANSPORT_CAPABILITY_CATALOG_V1[0]; let nostr = TRANSPORT_CAPABILITY_CATALOG_V1[1]; diff --git a/crates/replica_sync/src/ingest.rs b/crates/replica_sync/src/ingest.rs @@ -22,14 +22,16 @@ use radroots_event::event_head::{ }; use radroots_event::ids::RadrootsEventId; use radroots_event::kinds::{ - KIND_CALENDAR, KIND_FARM, KIND_LISTING, KIND_PLOT, KIND_PROFILE, is_nip51_list_set_kind, + KIND_CALENDAR, KIND_CLASSIFIED_LISTING, KIND_FARM, KIND_PLOT, KIND_PROFILE, + is_nip51_list_set_kind, }; -use radroots_event::listing::{ - RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, RadrootsListingStatus, +use radroots_event::operational_listing::{ + RadrootsOperationalListing, RadrootsOperationalListingAvailability, + RadrootsOperationalListingBin, RadrootsOperationalListingStatus, }; use radroots_event_codec::farm::decode as farm_decode; use radroots_event_codec::list_set::decode as list_set_decode; -use radroots_event_codec::listing::decode as listing_decode; +use radroots_event_codec::operational_listing::decode as listing_decode; use radroots_event_codec::plot::decode as plot_decode; use radroots_event_codec::profile::decode as profile_decode; use radroots_replica_schema::ReplicaSchemaError; @@ -206,7 +208,7 @@ fn ingest_event_inner( KIND_PROFILE => ingest_profile_event(exec, event), KIND_FARM => ingest_farm_event(exec, event, factory), KIND_PLOT => ingest_plot_event(exec, event, factory), - KIND_LISTING => ingest_listing_event(exec, event), + KIND_CLASSIFIED_LISTING => ingest_listing_event(exec, event), kind if is_nip51_list_set_kind(kind) && kind != KIND_CALENDAR => { ingest_list_set_event(exec, event) } @@ -473,7 +475,7 @@ fn ingest_listing_event( exec: &dyn SqlExecutor, event: &RadrootsEventEnvelope, ) -> Result<RadrootsReplicaIngestOutcome, RadrootsReplicaEventsError> { - let listing = listing_decode::listing_from_event( + let listing = listing_decode::operational_listing_from_event( event.kind_u32(), &event.tags_as_vec(), event.content(), @@ -561,7 +563,10 @@ fn ingest_list_set_event( )) } -fn listing_event_addr(event: &RadrootsEventEnvelope, listing: &RadrootsListing) -> String { +fn listing_event_addr( + event: &RadrootsEventEnvelope, + listing: &RadrootsOperationalListing, +) -> String { format!( "{}:{}:{}", event.kind_u32(), @@ -570,17 +575,17 @@ fn listing_event_addr(event: &RadrootsEventEnvelope, listing: &RadrootsListing) ) } -fn listing_is_orderable(listing: &RadrootsListing) -> bool { +fn listing_is_orderable(listing: &RadrootsOperationalListing) -> bool { match listing.availability.as_ref() { - Some(RadrootsListingAvailability::Status { status }) => { - matches!(status, RadrootsListingStatus::Active) + Some(RadrootsOperationalListingAvailability::Status { status }) => { + matches!(status, RadrootsOperationalListingStatus::Active) } - Some(RadrootsListingAvailability::Window { .. }) | None => true, + Some(RadrootsOperationalListingAvailability::Window { .. }) | None => true, } } fn trade_product_fields_from_listing( - listing: &RadrootsListing, + listing: &RadrootsOperationalListing, listing_addr: &str, ) -> Result<ITradeProductFields, RadrootsReplicaEventsError> { let bin = primary_listing_bin(listing)?; @@ -656,7 +661,7 @@ fn trade_product_fields_from_listing( } fn trade_product_notes_from_listing( - listing: &RadrootsListing, + listing: &RadrootsOperationalListing, ) -> Result<Option<String>, RadrootsReplicaEventsError> { let Some(discounts) = listing .discounts @@ -674,8 +679,8 @@ fn trade_product_notes_from_listing( } fn primary_listing_bin( - listing: &RadrootsListing, -) -> Result<&RadrootsListingBin, RadrootsReplicaEventsError> { + listing: &RadrootsOperationalListing, +) -> Result<&RadrootsOperationalListingBin, RadrootsReplicaEventsError> { listing .bins .iter() @@ -1542,7 +1547,7 @@ mod tests { use radroots_event::kinds::{KIND_LIST_SET_FOLLOW, KIND_LIST_SET_GENERIC}; use radroots_event::list::RadrootsListEntry; use radroots_event::list_set::RadrootsListSet; - use radroots_event::listing::RadrootsListingProduct; + use radroots_event::operational_listing::RadrootsOperationalListingProduct; use radroots_event::plot::{RadrootsPlot, RadrootsPlotLocation}; use radroots_event::profile::{ RADROOTS_PROFILE_TYPE_TAG_KEY, RadrootsProfileType, radroots_profile_type_tag_value, @@ -1917,7 +1922,7 @@ mod tests { id, author, u64::from(created_at), - KIND_LISTING, + KIND_CLASSIFIED_LISTING, vec![ vec!["d".to_string(), d_tag.to_string()], vec![ @@ -1968,15 +1973,15 @@ mod tests { "USD".parse().expect("currency") } - fn listing_model() -> RadrootsListing { - RadrootsListing { + fn listing_model() -> RadrootsOperationalListing { + RadrootsOperationalListing { d_tag: "AAAAAAAAAAAAAAAAAAAAAA".parse().expect("d tag"), published_at: Some(1), farm: RadrootsFarmRef { pubkey: "c".repeat(64), d_tag: "AAAAAAAAAAAAAAAAAAAAAZ".to_string(), }, - product: RadrootsListingProduct { + product: RadrootsOperationalListingProduct { key: "pasture-eggs".to_string(), title: "Pasture Eggs".to_string(), category: "eggs".to_string(), @@ -1988,7 +1993,7 @@ mod tests { year: Some("2026".to_string()), }, primary_bin_id: "bin-a".parse().expect("primary bin id"), - bins: vec![RadrootsListingBin { + bins: vec![RadrootsOperationalListingBin { bin_id: "bin-a".parse().expect("bin id"), quantity: RadrootsCoreQuantity::new(listing_decimal("12"), RadrootsCoreUnit::Each) .with_label("unit label"), @@ -2006,8 +2011,8 @@ mod tests { plot: None, discounts: Some(Vec::new()), inventory_available: Some(listing_decimal("5")), - availability: Some(RadrootsListingAvailability::Status { - status: RadrootsListingStatus::Active, + availability: Some(RadrootsOperationalListingAvailability::Status { + status: RadrootsOperationalListingStatus::Active, }), delivery_method: None, location: None, @@ -2624,7 +2629,10 @@ mod tests { let seller_pubkey = "c".repeat(64); let listing_d_tag = "AAAAAAAAAAAAAAAAAAAAAQ"; - let listing_addr = format!("{}:{}:{}", KIND_LISTING, seller_pubkey, listing_d_tag); + let listing_addr = format!( + "{}:{}:{}", + KIND_CLASSIFIED_LISTING, seller_pubkey, listing_d_tag + ); let mut active = listing_event( 500, @@ -2750,7 +2758,7 @@ mod tests { &exec, &INostrEventHeadFindOne::On(INostrEventHeadFindOneArgs { on: NostrEventHeadQueryBindValues::Key { - key: event_head_key(KIND_LISTING, &seller_pubkey, listing_d_tag), + key: event_head_key(KIND_CLASSIFIED_LISTING, &seller_pubkey, listing_d_tag), }, }), ) @@ -2789,7 +2797,10 @@ mod tests { let seller_pubkey = "c".repeat(64); let listing_d_tag = "AAAAAAAAAAAAAAAAAAAAAg"; - let listing_addr = format!("{}:{}:{}", KIND_LISTING, seller_pubkey, listing_d_tag); + let listing_addr = format!( + "{}:{}:{}", + KIND_CLASSIFIED_LISTING, seller_pubkey, listing_d_tag + ); let mut active = listing_event( 600, diff --git a/crates/trade/src/dto.rs b/crates/trade/src/dto.rs @@ -3,9 +3,9 @@ use dto_bindgen_core::{ StructDef, TargetFieldNames, TargetOverride, TypeDef, TypeRef, WireFieldNames, }; -use crate::listing::{ - model::{RadrootsTradeListingSubtotal, RadrootsTradeListingTotal}, - validation::RadrootsTradeListing, +use crate::operational_listing::{ + model::{RadrootsOperationalListingSubtotal, RadrootsOperationalListingTotal}, + validation::RadrootsOperationalListingTradeProjection, }; use crate::workflow::{ RadrootsTradeAgreementStateV1, RadrootsTradeAttestationStateV1, RadrootsTradeConflictStateV1, @@ -15,9 +15,9 @@ use crate::workflow::{ pub fn dto_roots() -> [RootDescriptor; 12] { [ - RootDescriptor::new::<RadrootsTradeListing>(), - RootDescriptor::new::<RadrootsTradeListingSubtotal>(), - RootDescriptor::new::<RadrootsTradeListingTotal>(), + RootDescriptor::new::<RadrootsOperationalListingTradeProjection>(), + RootDescriptor::new::<RadrootsOperationalListingSubtotal>(), + RootDescriptor::new::<RadrootsOperationalListingTotal>(), RootDescriptor::new::<RadrootsTradeNegotiationStateV1>(), RootDescriptor::new::<RadrootsTradeAgreementStateV1>(), RootDescriptor::new::<RadrootsTradeEvidenceStateV1>(), @@ -30,145 +30,149 @@ pub fn dto_roots() -> [RootDescriptor; 12] { ] } -impl Dto for RadrootsTradeListing { +impl Dto for RadrootsOperationalListingTradeProjection { fn describe(ctx: &mut DescribeCtx) -> TypeRef { let def = StructDef::new( - "RadrootsTradeListing", - "RadrootsTradeListing", - span("crates/trade/src/listing/validation.rs", 25), + "RadrootsOperationalListingTradeProjection", + "RadrootsOperationalListingTradeProjection", + span("crates/trade/src/operational_listing/validation.rs", 25), ) .with_field(field( "listing_id", "listing_id", String::describe(ctx), - "crates/trade/src/listing/validation.rs", + "crates/trade/src/operational_listing/validation.rs", 26, )) .with_field(field( "listing_addr", "listing_addr", String::describe(ctx), - "crates/trade/src/listing/validation.rs", + "crates/trade/src/operational_listing/validation.rs", 27, )) .with_field(field( "seller_pubkey", "seller_pubkey", String::describe(ctx), - "crates/trade/src/listing/validation.rs", + "crates/trade/src/operational_listing/validation.rs", 28, )) .with_field(field( "title", "title", String::describe(ctx), - "crates/trade/src/listing/validation.rs", + "crates/trade/src/operational_listing/validation.rs", 29, )) .with_field(field( "description", "description", String::describe(ctx), - "crates/trade/src/listing/validation.rs", + "crates/trade/src/operational_listing/validation.rs", 30, )) .with_field(field( "product_type", "product_type", String::describe(ctx), - "crates/trade/src/listing/validation.rs", + "crates/trade/src/operational_listing/validation.rs", 31, )) .with_field(field( "primary_bin_id", "primary_bin_id", String::describe(ctx), - "crates/trade/src/listing/validation.rs", + "crates/trade/src/operational_listing/validation.rs", 32, )) .with_field(field( "bin_quantity", "bin_quantity", ts_ref("RadrootsCoreQuantity"), - "crates/trade/src/listing/validation.rs", + "crates/trade/src/operational_listing/validation.rs", 33, )) .with_field(field( "unit", "unit", ts_ref("RadrootsCoreUnit"), - "crates/trade/src/listing/validation.rs", + "crates/trade/src/operational_listing/validation.rs", 34, )) .with_field(field( "unit_price", "unit_price", ts_ref("RadrootsCoreMoney"), - "crates/trade/src/listing/validation.rs", + "crates/trade/src/operational_listing/validation.rs", 35, )) .with_field(field( "inventory_available", "inventory_available", ts_ref("RadrootsCoreDecimal"), - "crates/trade/src/listing/validation.rs", + "crates/trade/src/operational_listing/validation.rs", 36, )) .with_field(field( "availability", "availability", - ts_ref("RadrootsListingAvailability"), - "crates/trade/src/listing/validation.rs", + ts_ref("RadrootsOperationalListingAvailability"), + "crates/trade/src/operational_listing/validation.rs", 37, )) .with_field(field( "location", "location", - ts_ref("RadrootsListingPublicLocation"), - "crates/trade/src/listing/validation.rs", + ts_ref("RadrootsOperationalListingPublicLocation"), + "crates/trade/src/operational_listing/validation.rs", 38, )) .with_field(field( "delivery_method", "delivery_method", - ts_ref("RadrootsListingDeliveryMethod"), - "crates/trade/src/listing/validation.rs", + ts_ref("RadrootsOperationalListingDeliveryMethod"), + "crates/trade/src/operational_listing/validation.rs", 39, )) .with_field(field( "listing", "listing", - ts_ref("RadrootsListing"), - "crates/trade/src/listing/validation.rs", + ts_ref("RadrootsOperationalListing"), + "crates/trade/src/operational_listing/validation.rs", 40, )); - register(ctx, "RadrootsTradeListing", TypeDef::Struct(def)) + register( + ctx, + "RadrootsOperationalListingTradeProjection", + TypeDef::Struct(def), + ) } } -impl Dto for RadrootsTradeListingSubtotal { +impl Dto for RadrootsOperationalListingSubtotal { fn describe(ctx: &mut DescribeCtx) -> TypeRef { - trade_listing_total_like( + operational_listing_total_like( ctx, - "RadrootsTradeListingSubtotal", - "crates/trade/src/listing/model.rs", + "RadrootsOperationalListingSubtotal", + "crates/trade/src/operational_listing/model.rs", 3, ) } } -impl Dto for RadrootsTradeListingTotal { +impl Dto for RadrootsOperationalListingTotal { fn describe(ctx: &mut DescribeCtx) -> TypeRef { - trade_listing_total_like( + operational_listing_total_like( ctx, - "RadrootsTradeListingTotal", - "crates/trade/src/listing/model.rs", + "RadrootsOperationalListingTotal", + "crates/trade/src/operational_listing/model.rs", 12, ) } } -fn trade_listing_total_like( +fn operational_listing_total_like( ctx: &mut DescribeCtx, rust_ident: &str, file: &str, @@ -240,9 +244,9 @@ mod tests { use super::dto_roots; const TRADE_SOURCE_ROOTS: &[&str] = &[ - "RadrootsTradeListing", - "RadrootsTradeListingSubtotal", - "RadrootsTradeListingTotal", + "RadrootsOperationalListingTradeProjection", + "RadrootsOperationalListingSubtotal", + "RadrootsOperationalListingTotal", "RadrootsTradeNegotiationStateV1", "RadrootsTradeAgreementStateV1", "RadrootsTradeEvidenceStateV1", @@ -276,8 +280,8 @@ mod tests { #[test] fn trade_source_fields_use_package_aliases_for_import_boundaries() { let registry = build_registry(dto_roots()); - let listing = find_struct(&registry, "RadrootsTradeListing"); - let subtotal = find_struct(&registry, "RadrootsTradeListingSubtotal"); + let listing = find_struct(&registry, "RadrootsOperationalListingTradeProjection"); + let subtotal = find_struct(&registry, "RadrootsOperationalListingSubtotal"); assert_eq!( typescript_override_target(field_ty(listing, "inventory_available")), @@ -285,7 +289,7 @@ mod tests { ); assert_eq!( typescript_override_target(field_ty(listing, "listing")), - Some("RadrootsListing") + Some("RadrootsOperationalListing") ); assert_eq!( typescript_override_target(field_ty(subtotal, "price_currency")), diff --git a/crates/trade/src/identity.rs b/crates/trade/src/identity.rs @@ -3,7 +3,7 @@ use core::str::FromStr; use radroots_event::ids::{ - RadrootsEventId, RadrootsIdParseError, RadrootsListingAddress, RadrootsOrderId, + RadrootsClassifiedListingAddress, RadrootsEventId, RadrootsIdParseError, RadrootsOrderId, RadrootsPublicKey, }; @@ -65,7 +65,7 @@ impl FromStr for RadrootsTradeId { pub struct RadrootsTradeLocator { pub trade_id: RadrootsTradeId, pub root_event_id: Option<RadrootsEventId>, - pub listing_addr: Option<RadrootsListingAddress>, + pub listing_addr: Option<RadrootsClassifiedListingAddress>, pub buyer_pubkey: Option<RadrootsPublicKey>, pub seller_pubkey: Option<RadrootsPublicKey>, } @@ -94,7 +94,7 @@ impl RadrootsTradeLocator { self } - pub fn with_listing_addr(mut self, listing_addr: RadrootsListingAddress) -> Self { + pub fn with_listing_addr(mut self, listing_addr: RadrootsClassifiedListingAddress) -> Self { self.listing_addr = Some(listing_addr); self } @@ -117,7 +117,7 @@ impl RadrootsTradeLocator { pub struct RadrootsTradeLocatorCandidate { pub trade_id: RadrootsTradeId, pub root_event_id: RadrootsEventId, - pub listing_addr: RadrootsListingAddress, + pub listing_addr: RadrootsClassifiedListingAddress, pub buyer_pubkey: RadrootsPublicKey, pub seller_pubkey: RadrootsPublicKey, } @@ -137,7 +137,7 @@ impl RadrootsTradeLocatorCandidate { #[cfg(test)] mod tests { use super::*; - use radroots_event::kinds::KIND_LISTING; + use radroots_event::kinds::KIND_CLASSIFIED_LISTING; const BUYER: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; @@ -154,9 +154,11 @@ mod tests { RadrootsPublicKey::parse(raw).expect("public key") } - fn listing_addr() -> RadrootsListingAddress { - RadrootsListingAddress::parse(format!("{KIND_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg")) - .expect("listing address") + fn listing_addr() -> RadrootsClassifiedListingAddress { + RadrootsClassifiedListingAddress::parse(format!( + "{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg" + )) + .expect("listing address") } #[test] diff --git a/crates/trade/src/lib.rs b/crates/trade/src/lib.rs @@ -6,7 +6,7 @@ extern crate alloc; #[cfg(feature = "dto-bindgen")] pub mod dto; pub mod identity; -pub mod listing; +pub mod operational_listing; pub mod prelude; #[cfg(feature = "serde_json")] pub mod validation_receipt; diff --git a/crates/trade/src/listing/draft.rs b/crates/trade/src/listing/draft.rs @@ -1,481 +0,0 @@ -//! Canonicalization for Radroots Listing v1 drafts. - -#![forbid(unsafe_code)] - -#[cfg(not(feature = "std"))] -use alloc::{format, string::ToString, vec::Vec}; - -#[cfg(feature = "std")] -use std::{string::ToString, vec::Vec}; - -use radroots_authority::RadrootsActorContext; -use radroots_event::{ - contract::RadrootsActorRole, - ids::{ - RadrootsIdParseError, RadrootsInventoryBinId, RadrootsListingAddress, RadrootsPublicKey, - }, - kinds::KIND_LISTING, - listing::RadrootsListing, -}; -use thiserror::Error; - -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Clone, Debug)] -pub struct RadrootsListingEditDocumentV1 { - pub listing: RadrootsListing, -} - -impl RadrootsListingEditDocumentV1 { - pub fn new(listing: RadrootsListing) -> Self { - Self { listing } - } -} - -#[cfg_attr(feature = "serde", derive(serde::Serialize))] -#[derive(Clone, Debug)] -pub struct RadrootsCanonicalListingEdit { - listing: RadrootsListing, - seller_pubkey: RadrootsPublicKey, - public_listing_addr: RadrootsListingAddress, -} - -impl RadrootsCanonicalListingEdit { - pub fn new( - mut listing: RadrootsListing, - seller_pubkey: RadrootsPublicKey, - ) -> Result<Self, RadrootsListingEditError> { - let farm_pubkey = RadrootsPublicKey::parse(listing.farm.pubkey.as_str()) - .map_err(RadrootsListingEditError::InvalidFarmPubkey)?; - if farm_pubkey != seller_pubkey { - return Err(RadrootsListingEditError::FarmPubkeyMismatch { - expected_pubkey: seller_pubkey, - actual_pubkey: farm_pubkey, - }); - } - listing.farm.pubkey = farm_pubkey.as_str().to_string(); - validate_listing_bins(&listing)?; - - let public_listing_addr = - listing_addr(KIND_LISTING, &seller_pubkey, listing.d_tag.as_str()); - - Ok(Self { - listing, - seller_pubkey, - public_listing_addr, - }) - } - - pub fn listing(&self) -> &RadrootsListing { - &self.listing - } - - pub fn seller_pubkey(&self) -> &RadrootsPublicKey { - &self.seller_pubkey - } - - pub fn public_listing_addr(&self) -> &RadrootsListingAddress { - &self.public_listing_addr - } -} - -#[derive(Clone, Debug, Error, PartialEq, Eq)] -pub enum RadrootsListingEditError { - #[error("invalid listing edit farm pubkey: {0}")] - InvalidFarmPubkey(RadrootsIdParseError), - #[error("invalid listing edit address: {0}")] - InvalidListingAddress(RadrootsIdParseError), - #[error("listing edit actor does not satisfy required role {required_role:?}")] - ActorRoleUnsatisfied { required_role: RadrootsActorRole }, - #[error("listing edit farm pubkey does not match seller")] - FarmPubkeyMismatch { - expected_pubkey: RadrootsPublicKey, - actual_pubkey: RadrootsPublicKey, - }, - #[error("listing edit primary bin is missing")] - MissingPrimaryBin { - primary_bin_id: RadrootsInventoryBinId, - }, - #[error("listing edit contains duplicate bin ID")] - DuplicateBinId { bin_id: RadrootsInventoryBinId }, -} - -fn validate_listing_bins(listing: &RadrootsListing) -> Result<(), RadrootsListingEditError> { - let primary_bin_id = listing.primary_bin_id.clone(); - let mut seen_bin_ids = Vec::new(); - let mut primary_bin_found = false; - for bin in &listing.bins { - if seen_bin_ids - .iter() - .any(|seen_bin_id| seen_bin_id == &bin.bin_id) - { - return Err(RadrootsListingEditError::DuplicateBinId { - bin_id: bin.bin_id.clone(), - }); - } - if bin.bin_id == primary_bin_id { - primary_bin_found = true; - } - seen_bin_ids.push(bin.bin_id.clone()); - } - - if !primary_bin_found { - return Err(RadrootsListingEditError::MissingPrimaryBin { primary_bin_id }); - } - Ok(()) -} - -fn listing_addr( - kind: u32, - seller_pubkey: &RadrootsPublicKey, - d_tag: &str, -) -> RadrootsListingAddress { - RadrootsListingAddress::parse(format!("{kind}:{}:{d_tag}", seller_pubkey.as_str())) - .expect("typed listing identity must form a listing address") -} - -pub fn canonicalize_listing_edit( - actor: &RadrootsActorContext, - mut document: RadrootsListingEditDocumentV1, -) -> Result<RadrootsCanonicalListingEdit, RadrootsListingEditError> { - if !actor.satisfies(RadrootsActorRole::Seller) { - return Err(RadrootsListingEditError::ActorRoleUnsatisfied { - required_role: RadrootsActorRole::Seller, - }); - } - - let seller_pubkey = actor.pubkey().clone(); - let farm_pubkey = document.listing.farm.pubkey.as_str(); - if farm_pubkey.is_empty() { - document.listing.farm.pubkey = seller_pubkey.as_str().to_string(); - } - - RadrootsCanonicalListingEdit::new(document.listing, seller_pubkey) -} - -#[cfg(test)] -mod tests { - use radroots_authority::RadrootsActorContext; - use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, - RadrootsCoreQuantityPrice, RadrootsCoreUnit, - }; - use radroots_event::{ - contract::RadrootsActorRole, - farm::RadrootsFarmRef, - ids::{RadrootsDTag, RadrootsInventoryBinId, RadrootsListingAddress, RadrootsPublicKey}, - kinds::KIND_LISTING, - listing::{RadrootsListing, RadrootsListingBin, RadrootsListingProduct}, - }; - - use super::{ - RadrootsCanonicalListingEdit, RadrootsListingEditDocumentV1, RadrootsListingEditError, - canonicalize_listing_edit, - }; - - const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - const OTHER: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; - - fn d_tag(raw: &str) -> RadrootsDTag { - RadrootsDTag::parse(raw).expect("d tag") - } - - fn bin_id(raw: &str) -> RadrootsInventoryBinId { - RadrootsInventoryBinId::parse(raw).expect("bin id") - } - - fn listing() -> RadrootsListing { - RadrootsListing { - d_tag: d_tag("AAAAAAAAAAAAAAAAAAAAAg"), - published_at: None, - farm: RadrootsFarmRef { - pubkey: SELLER.to_string(), - d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), - }, - product: RadrootsListingProduct { - key: "coffee".to_string(), - title: "Coffee".to_string(), - category: "coffee".to_string(), - summary: Some("Single origin coffee".to_string()), - process: None, - lot: None, - location: None, - profile: None, - year: None, - }, - primary_bin_id: bin_id("bin-1"), - bins: vec![RadrootsListingBin { - bin_id: bin_id("bin-1"), - quantity: RadrootsCoreQuantity::new( - RadrootsCoreDecimal::from(1000u32), - RadrootsCoreUnit::MassG, - ), - price_per_canonical_unit: RadrootsCoreQuantityPrice { - amount: RadrootsCoreMoney::new( - RadrootsCoreDecimal::from(20u32), - RadrootsCoreCurrency::USD, - ), - quantity: RadrootsCoreQuantity::new( - RadrootsCoreDecimal::from(1u32), - RadrootsCoreUnit::MassG, - ), - }, - display_amount: None, - display_unit: None, - display_label: None, - display_price: None, - display_price_unit: None, - }], - resource_area: None, - plot: None, - discounts: None, - inventory_available: None, - availability: None, - delivery_method: None, - location: None, - images: None, - } - } - - fn seller_actor() -> RadrootsActorContext { - RadrootsActorContext::explicit_pubkey(SELLER, [RadrootsActorRole::Seller]).expect("actor") - } - - fn buyer_actor() -> RadrootsActorContext { - RadrootsActorContext::explicit_pubkey(SELLER, [RadrootsActorRole::Buyer]).expect("actor") - } - - #[test] - fn draft_document_wraps_listing() { - let document = RadrootsListingEditDocumentV1::new(listing()); - - assert_eq!(document.listing.d_tag.as_str(), "AAAAAAAAAAAAAAAAAAAAAg"); - assert_eq!(document.listing.product.title, "Coffee"); - } - - #[cfg(feature = "serde_json")] - #[test] - fn draft_document_deserializes_as_untrusted_input() { - let json = serde_json::to_string(&RadrootsListingEditDocumentV1::new(listing())) - .expect("serialize document"); - - let document: RadrootsListingEditDocumentV1 = - serde_json::from_str(&json).expect("deserialize document"); - let canonical = - canonicalize_listing_edit(&seller_actor(), document).expect("canonical draft"); - - assert_eq!(canonical.seller_pubkey().as_str(), SELLER); - assert_eq!(canonical.listing().product.title, "Coffee"); - } - - #[test] - fn canonical_draft_carries_seller_listing_and_addresses() { - let seller_pubkey = RadrootsPublicKey::parse(SELLER).expect("seller"); - let listing = listing(); - - let canonical = - RadrootsCanonicalListingEdit::new(listing, seller_pubkey.clone()).expect("canonical"); - - assert_eq!(canonical.seller_pubkey(), &seller_pubkey); - assert_eq!( - canonical.public_listing_addr().as_str(), - format!("{KIND_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg") - ); - assert_eq!(canonical.listing().d_tag.as_str(), "AAAAAAAAAAAAAAAAAAAAAg"); - } - - #[test] - fn listing_edit_error_variants_are_precise() { - assert!(matches!( - RadrootsListingEditError::InvalidFarmPubkey( - RadrootsPublicKey::parse("bad").unwrap_err() - ), - RadrootsListingEditError::InvalidFarmPubkey(_) - )); - assert!(matches!( - RadrootsListingEditError::InvalidListingAddress( - RadrootsListingAddress::parse("bad").unwrap_err() - ), - RadrootsListingEditError::InvalidListingAddress(_) - )); - } - - #[test] - fn canonicalize_listing_edit_fills_missing_farm_pubkey_and_derives_address() { - let mut listing = listing(); - listing.farm.pubkey.clear(); - let document = RadrootsListingEditDocumentV1::new(listing); - - let canonical = - canonicalize_listing_edit(&seller_actor(), document).expect("canonical draft"); - - assert_eq!(canonical.seller_pubkey().as_str(), SELLER); - assert_eq!( - canonical.public_listing_addr().as_str(), - format!("{KIND_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg") - ); - assert_eq!(canonical.listing().farm.pubkey, SELLER); - } - - #[test] - fn canonicalize_listing_edit_rejects_non_seller_actor() { - let document = RadrootsListingEditDocumentV1::new(listing()); - - let error = canonicalize_listing_edit(&buyer_actor(), document).unwrap_err(); - - assert_eq!( - error, - RadrootsListingEditError::ActorRoleUnsatisfied { - required_role: RadrootsActorRole::Seller - } - ); - } - - #[test] - fn canonicalize_listing_edit_rejects_mismatched_farm_pubkey() { - let mut listing = listing(); - listing.farm.pubkey = OTHER.to_string(); - let document = RadrootsListingEditDocumentV1::new(listing); - - let error = canonicalize_listing_edit(&seller_actor(), document).unwrap_err(); - - assert!(matches!( - error, - RadrootsListingEditError::FarmPubkeyMismatch { .. } - )); - } - - #[test] - fn canonicalize_listing_edit_rejects_invalid_farm_pubkey() { - let mut listing = listing(); - listing.farm.pubkey = "bad".to_string(); - let document = RadrootsListingEditDocumentV1::new(listing); - - let error = canonicalize_listing_edit(&seller_actor(), document).unwrap_err(); - - assert!(matches!( - error, - RadrootsListingEditError::InvalidFarmPubkey(_) - )); - } - - #[test] - fn canonical_draft_new_rejects_mismatched_farm_pubkey() { - let mut listing = listing(); - listing.farm.pubkey = OTHER.to_string(); - - let error = RadrootsCanonicalListingEdit::new( - listing, - RadrootsPublicKey::parse(SELLER).expect("seller"), - ) - .unwrap_err(); - - assert!(matches!( - error, - RadrootsListingEditError::FarmPubkeyMismatch { .. } - )); - } - - #[test] - fn canonical_draft_new_rejects_invalid_farm_pubkey() { - let mut listing = listing(); - listing.farm.pubkey = "bad".to_string(); - - let error = RadrootsCanonicalListingEdit::new( - listing, - RadrootsPublicKey::parse(SELLER).expect("seller"), - ) - .unwrap_err(); - - assert!(matches!( - error, - RadrootsListingEditError::InvalidFarmPubkey(_) - )); - } - - #[test] - fn canonical_draft_new_rejects_empty_farm_pubkey() { - let mut listing = listing(); - listing.farm.pubkey.clear(); - - let error = RadrootsCanonicalListingEdit::new( - listing, - RadrootsPublicKey::parse(SELLER).expect("seller"), - ) - .unwrap_err(); - - assert!(matches!( - error, - RadrootsListingEditError::InvalidFarmPubkey(_) - )); - } - - #[test] - fn canonicalize_listing_edit_rejects_missing_primary_bin() { - let mut listing = listing(); - listing.primary_bin_id = bin_id("bin-2"); - let document = RadrootsListingEditDocumentV1::new(listing); - - let error = canonicalize_listing_edit(&seller_actor(), document).unwrap_err(); - - assert_eq!( - error, - RadrootsListingEditError::MissingPrimaryBin { - primary_bin_id: bin_id("bin-2") - } - ); - } - - #[test] - fn canonical_draft_new_rejects_missing_primary_bin() { - let mut listing = listing(); - listing.primary_bin_id = bin_id("bin-2"); - - let error = RadrootsCanonicalListingEdit::new( - listing, - RadrootsPublicKey::parse(SELLER).expect("seller"), - ) - .unwrap_err(); - - assert_eq!( - error, - RadrootsListingEditError::MissingPrimaryBin { - primary_bin_id: bin_id("bin-2") - } - ); - } - - #[test] - fn canonicalize_listing_edit_rejects_duplicate_bin_ids() { - let mut listing = listing(); - listing.bins.push(listing.bins[0].clone()); - let document = RadrootsListingEditDocumentV1::new(listing); - - let error = canonicalize_listing_edit(&seller_actor(), document).unwrap_err(); - - assert_eq!( - error, - RadrootsListingEditError::DuplicateBinId { - bin_id: bin_id("bin-1") - } - ); - } - - #[test] - fn canonical_draft_new_rejects_duplicate_bin_ids() { - let mut listing = listing(); - listing.bins.push(listing.bins[0].clone()); - - let error = RadrootsCanonicalListingEdit::new( - listing, - RadrootsPublicKey::parse(SELLER).expect("seller"), - ) - .unwrap_err(); - - assert_eq!( - error, - RadrootsListingEditError::DuplicateBinId { - bin_id: bin_id("bin-1") - } - ); - } -} diff --git a/crates/trade/src/listing/mod.rs b/crates/trade/src/listing/mod.rs @@ -1,290 +0,0 @@ -pub mod draft; -pub mod model; -pub mod mutation; -pub mod price_ext; -pub mod validation; - -use radroots_event::{ - RadrootsEventEnvelope, - ids::{ - RadrootsAddressableCoordinateParts, RadrootsDTag, RadrootsIdParseError, - RadrootsListingAddress, RadrootsPublicKey, - }, - kinds::is_listing_kind, - listing::RadrootsListing, -}; -use radroots_event_codec::listing::decode::listing_from_nostr_event; -use thiserror::Error; - -pub use self::draft::{ - RadrootsCanonicalListingEdit, RadrootsListingEditDocumentV1, RadrootsListingEditError, - canonicalize_listing_edit, -}; -#[cfg(feature = "serde_json")] -pub use self::mutation::build_listing_mutation_draft; -pub use self::mutation::{ - RadrootsListingLifecycleState, RadrootsListingMutation, RadrootsListingMutationError, -}; -pub use radroots_event::order::RadrootsListingParseError as ListingParseError; - -#[derive(Clone, Debug, Error, PartialEq, Eq)] -pub enum RadrootsListingAddressError { - #[error("invalid listing address: {0}")] - InvalidAddress(RadrootsIdParseError), - #[error("listing address must reference a listing kind")] - InvalidKind { actual: u32 }, -} - -#[derive(Clone, Debug, Error, PartialEq, Eq)] -pub enum RadrootsPublicListingAddressError { - #[error("invalid listing address: {0}")] - InvalidAddress(RadrootsIdParseError), - #[error("listing address must reference a listing kind")] - InvalidListingKind { actual: u32 }, - #[error("listing address must reference a public NIP-99 listing")] - InvalidKind { actual: u32 }, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsListingAddressParts { - pub address: RadrootsListingAddress, - pub kind: u32, - pub seller_pubkey: RadrootsPublicKey, - pub listing_id: RadrootsDTag, -} - -impl RadrootsListingAddressParts { - pub fn parse(value: impl AsRef<str>) -> Result<Self, RadrootsListingAddressError> { - parse_listing_address(value) - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsPublicListingAddress { - pub address: RadrootsListingAddress, - pub kind: u32, - pub seller_pubkey: RadrootsPublicKey, - pub listing_id: RadrootsDTag, -} - -impl RadrootsPublicListingAddress { - pub fn parse(value: impl AsRef<str>) -> Result<Self, RadrootsPublicListingAddressError> { - parse_public_listing_address(value) - } -} - -pub fn parse_listing_address( - value: impl AsRef<str>, -) -> Result<RadrootsListingAddressParts, RadrootsListingAddressError> { - let value = value.as_ref(); - let address = RadrootsListingAddress::parse(value) - .map_err(RadrootsListingAddressError::InvalidAddress)?; - let parts = RadrootsAddressableCoordinateParts::parse(address.as_str()) - .expect("typed listing address must contain valid coordinate parts"); - ensure_listing_kind(parts.kind)?; - Ok(RadrootsListingAddressParts { - address, - kind: parts.kind, - seller_pubkey: parts.pubkey, - listing_id: parts.d_tag, - }) -} - -pub fn parse_public_listing_address( - value: impl AsRef<str>, -) -> Result<RadrootsPublicListingAddress, RadrootsPublicListingAddressError> { - let parts = parse_listing_address(value).map_err(|error| match error { - RadrootsListingAddressError::InvalidAddress(error) => { - RadrootsPublicListingAddressError::InvalidAddress(error) - } - RadrootsListingAddressError::InvalidKind { actual } => { - RadrootsPublicListingAddressError::InvalidListingKind { actual } - } - })?; - Ok(RadrootsPublicListingAddress { - address: parts.address, - kind: parts.kind, - seller_pubkey: parts.seller_pubkey, - listing_id: parts.listing_id, - }) -} - -#[cfg_attr(coverage_nightly, coverage(off))] -fn ensure_listing_kind(kind: u32) -> Result<(), RadrootsListingAddressError> { - if !is_listing_kind(kind) { - return Err(RadrootsListingAddressError::InvalidKind { actual: kind }); - } - Ok(()) -} - -pub fn parse_listing_event( - event: &RadrootsEventEnvelope, -) -> Result<RadrootsListing, ListingParseError> { - listing_from_nostr_event(event) -} - -#[cfg(test)] -mod tests { - use super::{ - RadrootsListingAddressError, RadrootsListingAddressParts, RadrootsPublicListingAddress, - RadrootsPublicListingAddressError, parse_listing_address, parse_listing_event, - parse_public_listing_address, - }; - use radroots_event::{ - RadrootsEventEnvelope, RadrootsEventEnvelopeParts, - ids::RadrootsListingAddress, - kinds::{KIND_LISTING, KIND_PROFILE}, - order::RadrootsListingParseError, - }; - - const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - - fn listing_event() -> RadrootsEventEnvelope { - RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { - id: "9".repeat(64), - author: SELLER.to_string(), - created_at: 1, - kind: KIND_LISTING, - tags: vec![ - vec!["d".into(), "AAAAAAAAAAAAAAAAAAAAAg".into()], - vec!["p".into(), SELLER.into()], - vec!["a".into(), format!("30340:{SELLER}:AAAAAAAAAAAAAAAAAAAAAA")], - vec!["key".into(), "coffee".into()], - vec!["title".into(), "Coffee".into()], - vec!["category".into(), "coffee".into()], - vec!["summary".into(), "Single origin".into()], - vec!["radroots:primary_bin".into(), "bin-1".into()], - vec![ - "radroots:bin".into(), - "bin-1".into(), - "1000".into(), - "g".into(), - ], - vec![ - "radroots:price".into(), - "bin-1".into(), - "20".into(), - "USD".into(), - "1".into(), - "g".into(), - ], - ], - content: String::new(), - sig: "f".repeat(128), - }) - .expect("listing event") - } - - #[test] - fn parse_listing_event_rejects_non_listing_kind() { - let event = RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { - id: "8".repeat(64), - author: SELLER.to_string(), - created_at: 1, - kind: KIND_PROFILE, - tags: vec![], - content: String::new(), - sig: "f".repeat(128), - }) - .expect("profile event"); - - assert!(matches!( - parse_listing_event(&event), - Err(RadrootsListingParseError::InvalidKind(KIND_PROFILE)) - )); - } - - #[test] - fn parse_listing_event_accepts_listing_kind() { - let listing = parse_listing_event(&listing_event()).expect("listing"); - - assert_eq!(listing.d_tag.as_str(), "AAAAAAAAAAAAAAAAAAAAAg"); - assert_eq!(listing.farm.pubkey, SELLER); - assert_eq!(listing.primary_bin_id.as_str(), "bin-1"); - } - - #[test] - fn listing_address_associated_parsers_delegate_to_public_parsers() { - let raw = format!("{KIND_LISTING}:{SELLER}:listing-1"); - - let listing = RadrootsListingAddressParts::parse(raw.clone()).expect("listing address"); - let public = RadrootsPublicListingAddress::parse(&raw).expect("public address"); - let typed = - parse_public_listing_address(RadrootsListingAddress::parse(&raw).expect("typed addr")) - .expect("typed public address"); - - assert_eq!(listing.address.as_str(), raw); - assert_eq!(public.address.as_str(), raw); - assert_eq!(typed.address.as_str(), raw); - assert_eq!(listing.seller_pubkey.as_str(), SELLER); - assert_eq!(public.seller_pubkey.as_str(), SELLER); - assert_eq!(typed.seller_pubkey.as_str(), SELLER); - } - - #[test] - fn parse_public_listing_address_accepts_public_listing_kind() { - let raw = format!("{KIND_LISTING}:{SELLER}:listing-1"); - let parsed = parse_public_listing_address(&raw).expect("public listing address"); - - assert_eq!(parsed.address.as_str(), raw); - assert_eq!(parsed.kind, KIND_LISTING); - assert_eq!(parsed.seller_pubkey.as_str(), SELLER); - assert_eq!(parsed.listing_id.as_str(), "listing-1"); - } - - #[test] - fn parse_listing_address_rejects_retired_listing_kind() { - let raw = format!("30403:{SELLER}:listing-1"); - - assert!(matches!( - parse_listing_address(&raw), - Err(RadrootsListingAddressError::InvalidKind { actual: 30403 }) - )); - assert!(matches!( - parse_public_listing_address(&raw), - Err(RadrootsPublicListingAddressError::InvalidListingKind { actual: 30403 }) - )); - } - - #[test] - fn parse_public_listing_address_maps_invalid_listing_addresses() { - assert!(matches!( - parse_public_listing_address("not-an-address"), - Err(RadrootsPublicListingAddressError::InvalidAddress(_)) - )); - - let raw = format!("{KIND_PROFILE}:{SELLER}:listing-1"); - assert!(matches!( - parse_public_listing_address(&raw), - Err(RadrootsPublicListingAddressError::InvalidListingKind { - actual: KIND_PROFILE - }) - )); - - let typed = RadrootsListingAddress::parse(&raw).expect("typed profile address"); - assert!(matches!( - parse_public_listing_address(typed.clone()), - Err(RadrootsPublicListingAddressError::InvalidListingKind { - actual: KIND_PROFILE - }) - )); - assert!(matches!( - parse_public_listing_address(&typed), - Err(RadrootsPublicListingAddressError::InvalidListingKind { - actual: KIND_PROFILE - }) - )); - } - - #[test] - fn parse_listing_address_rejects_non_listing_kind() { - let raw = format!("{KIND_PROFILE}:{SELLER}:listing-1"); - - assert!(matches!( - parse_listing_address(&raw), - Err(RadrootsListingAddressError::InvalidKind { - actual: KIND_PROFILE - }) - )); - } -} diff --git a/crates/trade/src/listing/model.rs b/crates/trade/src/listing/model.rs @@ -1,17 +0,0 @@ -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsTradeListingSubtotal { - pub price_amount: radroots_core::RadrootsCoreMoney, - pub price_currency: radroots_core::RadrootsCoreCurrency, - pub quantity_amount: radroots_core::RadrootsCoreDecimal, - pub quantity_unit: radroots_core::RadrootsCoreUnit, -} - -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsTradeListingTotal { - pub price_amount: radroots_core::RadrootsCoreMoney, - pub price_currency: radroots_core::RadrootsCoreCurrency, - pub quantity_amount: radroots_core::RadrootsCoreDecimal, - pub quantity_unit: radroots_core::RadrootsCoreUnit, -} diff --git a/crates/trade/src/listing/mutation.rs b/crates/trade/src/listing/mutation.rs @@ -1,453 +0,0 @@ -//! Mutation draft preparation for Radroots Listing v1. - -#![forbid(unsafe_code)] - -#[cfg(all(feature = "serde_json", not(feature = "std")))] -use alloc::string::{String, ToString}; - -#[cfg(all(feature = "serde_json", feature = "std"))] -use std::string::{String, ToString}; - -use radroots_event::ids::RadrootsListingAddress; -#[cfg(feature = "serde_json")] -use radroots_event::{ - draft::{RadrootsDraftError, RadrootsEventDraft}, - kinds::KIND_LISTING, -}; -#[cfg(feature = "serde_json")] -use radroots_event_codec::listing::encode::to_json_wire_parts_with_kind; -use thiserror::Error; - -use crate::listing::draft::RadrootsCanonicalListingEdit; - -/// Listing v1 mutation intent for draft preparation only. -/// -/// Publish and update target the public listing event, while local-only draft -/// persistence and archive are intentionally unsupported as wire events. -#[derive(Clone, Debug)] -pub enum RadrootsListingMutation { - Publish { - draft: RadrootsCanonicalListingEdit, - }, - Update { - draft: RadrootsCanonicalListingEdit, - }, - SaveDraft { - draft: RadrootsCanonicalListingEdit, - }, - Archive { - listing_addr: RadrootsListingAddress, - }, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum RadrootsListingLifecycleState { - Draft, - Published, -} - -#[derive(Clone, Debug, Error, PartialEq, Eq)] -pub enum RadrootsListingMutationError { - #[error("listing mutation is not supported")] - UnsupportedMutation, - #[cfg(feature = "serde_json")] - #[error("failed to encode listing mutation: {0}")] - EncodeListing(String), - #[cfg(feature = "serde_json")] - #[error("failed to build listing mutation draft: {0}")] - FrozenDraft(RadrootsDraftError), -} - -const LISTING_PUBLISHED_CONTRACT_ID: &str = "radroots.listing.published.v1"; - -impl RadrootsListingMutation { - pub fn publish(draft: RadrootsCanonicalListingEdit) -> Self { - Self::Publish { draft } - } - - pub fn update(draft: RadrootsCanonicalListingEdit) -> Self { - Self::Update { draft } - } - - pub fn save_draft(draft: RadrootsCanonicalListingEdit) -> Self { - Self::SaveDraft { draft } - } - - pub fn archive(listing_addr: RadrootsListingAddress) -> Self { - Self::Archive { listing_addr } - } - - pub fn lifecycle_state( - &self, - ) -> Result<RadrootsListingLifecycleState, RadrootsListingMutationError> { - match self { - Self::Publish { .. } | Self::Update { .. } => { - Ok(RadrootsListingLifecycleState::Published) - } - Self::SaveDraft { .. } => Ok(RadrootsListingLifecycleState::Draft), - Self::Archive { .. } => Err(RadrootsListingMutationError::UnsupportedMutation), - } - } - - pub fn canonical_draft( - &self, - ) -> Result<&RadrootsCanonicalListingEdit, RadrootsListingMutationError> { - match self { - Self::Publish { draft } | Self::Update { draft } | Self::SaveDraft { draft } => { - Ok(draft) - } - Self::Archive { .. } => Err(RadrootsListingMutationError::UnsupportedMutation), - } - } - - pub fn listing_addr(&self) -> Result<&RadrootsListingAddress, RadrootsListingMutationError> { - match self { - Self::Publish { draft } | Self::Update { draft } => Ok(draft.public_listing_addr()), - Self::SaveDraft { draft } => Ok(draft.public_listing_addr()), - Self::Archive { .. } => Err(RadrootsListingMutationError::UnsupportedMutation), - } - } -} - -#[cfg(feature = "serde_json")] -pub fn build_listing_mutation_draft( - mutation: &RadrootsListingMutation, - created_at: u64, -) -> Result<RadrootsEventDraft, RadrootsListingMutationError> { - let (draft, kind, contract_id) = match mutation { - RadrootsListingMutation::Publish { draft } | RadrootsListingMutation::Update { draft } => { - (draft, KIND_LISTING, LISTING_PUBLISHED_CONTRACT_ID) - } - RadrootsListingMutation::SaveDraft { .. } | RadrootsListingMutation::Archive { .. } => { - return Err(RadrootsListingMutationError::UnsupportedMutation); - } - }; - let parts = to_json_wire_parts_with_kind(draft.listing(), kind) - .map_err(|error| RadrootsListingMutationError::EncodeListing(error.to_string()))?; - RadrootsEventDraft::new( - contract_id, - parts.kind, - created_at, - parts.tags, - parts.content, - draft.seller_pubkey().as_str(), - ) - .map_err(RadrootsListingMutationError::FrozenDraft) -} - -#[cfg(test)] -mod tests { - use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, - RadrootsCoreQuantityPrice, RadrootsCoreUnit, - }; - use radroots_event::{ - RadrootsEventEnvelope, RadrootsEventEnvelopeParts, - farm::RadrootsFarmRef, - ids::{RadrootsDTag, RadrootsInventoryBinId, RadrootsListingAddress, RadrootsPublicKey}, - kinds::KIND_LISTING, - listing::{ - RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, - RadrootsListingDeliveryMethod, RadrootsListingProduct, RadrootsListingPublicLocation, - RadrootsListingStatus, - }, - resource_area::RadrootsResourceAreaRef, - }; - - use crate::listing::draft::RadrootsCanonicalListingEdit; - use crate::listing::validation::validate_listing_event; - - use super::{ - LISTING_PUBLISHED_CONTRACT_ID, RadrootsListingLifecycleState, RadrootsListingMutation, - RadrootsListingMutationError, build_listing_mutation_draft, - }; - - const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - - fn d_tag(raw: &str) -> RadrootsDTag { - RadrootsDTag::parse(raw).expect("d tag") - } - - fn bin_id(raw: &str) -> RadrootsInventoryBinId { - RadrootsInventoryBinId::parse(raw).expect("bin id") - } - - fn listing() -> RadrootsListing { - RadrootsListing { - d_tag: d_tag("AAAAAAAAAAAAAAAAAAAAAg"), - published_at: None, - farm: RadrootsFarmRef { - pubkey: SELLER.to_string(), - d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), - }, - product: RadrootsListingProduct { - key: "coffee".to_string(), - title: "Coffee".to_string(), - category: "coffee".to_string(), - summary: Some("Single origin coffee".to_string()), - process: None, - lot: None, - location: None, - profile: None, - year: None, - }, - primary_bin_id: bin_id("bin-1"), - bins: vec![RadrootsListingBin { - bin_id: bin_id("bin-1"), - quantity: RadrootsCoreQuantity::new( - RadrootsCoreDecimal::from(1000u32), - RadrootsCoreUnit::MassG, - ), - price_per_canonical_unit: RadrootsCoreQuantityPrice { - amount: RadrootsCoreMoney::new( - RadrootsCoreDecimal::from(20u32), - RadrootsCoreCurrency::USD, - ), - quantity: RadrootsCoreQuantity::new( - RadrootsCoreDecimal::from(1u32), - RadrootsCoreUnit::MassG, - ), - }, - display_amount: None, - display_unit: None, - display_label: None, - display_price: None, - display_price_unit: None, - }], - resource_area: None, - plot: None, - discounts: None, - inventory_available: Some(RadrootsCoreDecimal::from(5u32)), - availability: Some(RadrootsListingAvailability::Status { - status: RadrootsListingStatus::Active, - }), - delivery_method: Some(RadrootsListingDeliveryMethod::Pickup), - location: Some(RadrootsListingPublicLocation { - primary: "Farm".to_string(), - city: Some("Town".to_string()), - region: Some("Region".to_string()), - country: Some("US".to_string()), - geohash: "9q8yy".to_string(), - }), - images: None, - } - } - - fn canonical_draft() -> RadrootsCanonicalListingEdit { - RadrootsCanonicalListingEdit::new( - listing(), - RadrootsPublicKey::parse(SELLER).expect("seller"), - ) - .expect("canonical listing edit") - } - - #[test] - fn supported_mutations_report_lifecycle_states() { - assert_eq!( - RadrootsListingMutation::publish(canonical_draft()) - .lifecycle_state() - .expect("state"), - RadrootsListingLifecycleState::Published - ); - assert_eq!( - RadrootsListingMutation::update(canonical_draft()) - .lifecycle_state() - .expect("state"), - RadrootsListingLifecycleState::Published - ); - assert_eq!( - RadrootsListingMutation::save_draft(canonical_draft()) - .lifecycle_state() - .expect("state"), - RadrootsListingLifecycleState::Draft - ); - } - - #[test] - fn supported_mutations_expose_canonical_drafts() { - let publish = RadrootsListingMutation::publish(canonical_draft()); - let update = RadrootsListingMutation::update(canonical_draft()); - let save_draft = RadrootsListingMutation::save_draft(canonical_draft()); - - assert_eq!( - publish - .canonical_draft() - .expect("draft") - .seller_pubkey() - .as_str(), - SELLER - ); - assert_eq!( - update - .canonical_draft() - .expect("draft") - .seller_pubkey() - .as_str(), - SELLER - ); - assert_eq!( - save_draft - .canonical_draft() - .expect("draft") - .seller_pubkey() - .as_str(), - SELLER - ); - assert_eq!( - publish - .canonical_draft() - .expect("draft") - .listing() - .d_tag - .as_str(), - "AAAAAAAAAAAAAAAAAAAAAg" - ); - } - - #[test] - fn supported_mutations_report_listing_addresses() { - let publish = RadrootsListingMutation::publish(canonical_draft()); - let update = RadrootsListingMutation::update(canonical_draft()); - let save_draft = RadrootsListingMutation::save_draft(canonical_draft()); - - assert_eq!( - publish.listing_addr().expect("address").as_str(), - format!("{KIND_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg") - ); - assert_eq!( - update.listing_addr().expect("address").as_str(), - format!("{KIND_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg") - ); - assert_eq!( - save_draft.listing_addr().expect("address").as_str(), - format!("{KIND_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg") - ); - } - - #[test] - fn archive_is_explicitly_unsupported() { - let archive = RadrootsListingMutation::archive( - RadrootsListingAddress::parse(format!( - "{KIND_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg" - )) - .expect("listing address"), - ); - - assert_eq!( - archive.lifecycle_state().unwrap_err(), - RadrootsListingMutationError::UnsupportedMutation - ); - assert_eq!( - archive.canonical_draft().unwrap_err(), - RadrootsListingMutationError::UnsupportedMutation - ); - assert_eq!( - archive.listing_addr().unwrap_err(), - RadrootsListingMutationError::UnsupportedMutation - ); - } - - #[test] - fn build_listing_mutation_draft_maps_publish_and_update_to_published_listing() { - let publish = RadrootsListingMutation::publish(canonical_draft()); - let update = RadrootsListingMutation::update(canonical_draft()); - - let publish_draft = build_listing_mutation_draft(&publish, 1_700_000_000).expect("draft"); - let update_draft = build_listing_mutation_draft(&update, 1_700_000_000).expect("draft"); - - assert_eq!(publish_draft.kind_u32(), KIND_LISTING); - assert_eq!(publish_draft.contract_id(), LISTING_PUBLISHED_CONTRACT_ID); - assert_eq!(publish_draft.expected_pubkey_str(), SELLER); - assert_eq!(publish_draft.created_at_u64(), 1_700_000_000); - let published_content: RadrootsListing = - serde_json::from_str(publish_draft.content()).expect("listing json"); - assert_eq!(published_content.d_tag.as_str(), "AAAAAAAAAAAAAAAAAAAAAg"); - assert_eq!(update_draft.kind_u32(), KIND_LISTING); - assert_eq!(update_draft.contract_id(), LISTING_PUBLISHED_CONTRACT_ID); - assert_eq!(update_draft.expected_pubkey_str(), SELLER); - } - - #[test] - fn build_listing_mutation_draft_rejects_save_draft() { - let save_draft = RadrootsListingMutation::save_draft(canonical_draft()); - - assert_eq!( - build_listing_mutation_draft(&save_draft, 1_700_000_000).unwrap_err(), - RadrootsListingMutationError::UnsupportedMutation - ); - } - - #[test] - fn build_listing_mutation_draft_rejects_archive() { - let archive = RadrootsListingMutation::archive( - RadrootsListingAddress::parse(format!( - "{KIND_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg" - )) - .expect("listing address"), - ); - - assert_eq!( - build_listing_mutation_draft(&archive, 1_700_000_000).unwrap_err(), - RadrootsListingMutationError::UnsupportedMutation - ); - } - - #[test] - fn build_listing_mutation_draft_reports_encode_errors() { - let mut listing = listing(); - listing.resource_area = Some(RadrootsResourceAreaRef { - pubkey: SELLER.to_string(), - d_tag: "bad d tag".to_string(), - }); - let draft = RadrootsCanonicalListingEdit::new( - listing, - RadrootsPublicKey::parse(SELLER).expect("seller"), - ) - .expect("canonical listing edit"); - let publish = RadrootsListingMutation::publish(draft); - - let err = build_listing_mutation_draft(&publish, 1_700_000_000).unwrap_err(); - - assert!(matches!( - err, - RadrootsListingMutationError::EncodeListing(_) - )); - } - - #[test] - fn build_listing_mutation_draft_event_id_is_stable_for_fixed_input() { - let publish = RadrootsListingMutation::publish(canonical_draft()); - - let first = build_listing_mutation_draft(&publish, 1_700_000_000).expect("draft"); - let second = build_listing_mutation_draft(&publish, 1_700_000_000).expect("draft"); - - assert_eq!( - first.expected_event_id_str(), - second.expected_event_id_str() - ); - assert_eq!(first.expected_event_id_str().len(), 64); - assert_eq!(first.tags_as_vec(), second.tags_as_vec()); - assert_eq!(first.content(), second.content()); - } - - #[test] - fn build_listing_mutation_draft_output_validates_as_trade_listing() { - let publish = RadrootsListingMutation::publish(canonical_draft()); - let draft = build_listing_mutation_draft(&publish, 1_700_000_000).expect("draft"); - - let event = RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { - id: draft.expected_event_id_str().to_owned(), - author: draft.expected_pubkey_str().to_owned(), - created_at: draft.created_at_u64(), - kind: draft.kind_u32(), - tags: draft.tags_as_vec(), - content: draft.content().to_owned(), - sig: "f".repeat(128), - }) - .expect("listing event"); - let validated = validate_listing_event(&event).expect("validated listing"); - - assert_eq!(validated.seller_pubkey, SELLER); - assert!(validated.listing_addr.contains(&format!(":{SELLER}:"))); - } -} diff --git a/crates/trade/src/listing/price_ext.rs b/crates/trade/src/listing/price_ext.rs @@ -1,215 +0,0 @@ -use crate::listing::model::{RadrootsTradeListingSubtotal, RadrootsTradeListingTotal}; -use radroots_core::{ - RadrootsCoreDecimal, RadrootsCoreQuantity, RadrootsCoreQuantityPriceError, - RadrootsCoreQuantityPriceOps, -}; -use radroots_event::listing::RadrootsListingBin; - -pub trait BinPricingExt { - fn subtotal_for_count(&self, bin_count: u32) -> RadrootsTradeListingSubtotal; - fn total_for_count(&self, bin_count: u32) -> RadrootsTradeListingTotal; -} - -pub trait BinPricingTryExt { - fn try_subtotal_for_count( - &self, - bin_count: u32, - ) -> Result<RadrootsTradeListingSubtotal, RadrootsCoreQuantityPriceError>; - fn try_total_for_count( - &self, - bin_count: u32, - ) -> Result<RadrootsTradeListingTotal, RadrootsCoreQuantityPriceError>; -} - -#[inline] -fn effective_quantity(bin: &RadrootsListingBin, bin_count: u32) -> RadrootsCoreQuantity { - let amount = bin.quantity.amount * RadrootsCoreDecimal::from(bin_count); - RadrootsCoreQuantity::new(amount, bin.quantity.unit) -} - -impl BinPricingExt for RadrootsListingBin { - fn subtotal_for_count(&self, bin_count: u32) -> RadrootsTradeListingSubtotal { - let effective_qty = effective_quantity(self, bin_count); - let money = self - .price_per_canonical_unit - .cost_for_rounded(&effective_qty); - let currency = money.currency; - - RadrootsTradeListingSubtotal { - price_amount: money, - price_currency: currency, - quantity_amount: effective_qty.amount, - quantity_unit: effective_qty.unit, - } - } - - fn total_for_count(&self, bin_count: u32) -> RadrootsTradeListingTotal { - let sub = self.subtotal_for_count(bin_count); - RadrootsTradeListingTotal { - price_amount: sub.price_amount, - price_currency: sub.price_currency, - quantity_amount: sub.quantity_amount, - quantity_unit: sub.quantity_unit, - } - } -} - -impl BinPricingTryExt for RadrootsListingBin { - fn try_subtotal_for_count( - &self, - bin_count: u32, - ) -> Result<RadrootsTradeListingSubtotal, RadrootsCoreQuantityPriceError> { - let effective_qty = effective_quantity(self, bin_count); - let money = self - .price_per_canonical_unit - .try_cost_for_rounded(&effective_qty)?; - let currency = money.currency; - - Ok(RadrootsTradeListingSubtotal { - price_amount: money, - price_currency: currency, - quantity_amount: effective_qty.amount, - quantity_unit: effective_qty.unit, - }) - } - - fn try_total_for_count( - &self, - bin_count: u32, - ) -> Result<RadrootsTradeListingTotal, RadrootsCoreQuantityPriceError> { - let sub = self.try_subtotal_for_count(bin_count)?; - Ok(RadrootsTradeListingTotal { - price_amount: sub.price_amount, - price_currency: sub.price_currency, - quantity_amount: sub.quantity_amount, - quantity_unit: sub.quantity_unit, - }) - } -} - -#[cfg(test)] -mod tests { - use super::{BinPricingExt, BinPricingTryExt}; - use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, - RadrootsCoreQuantityPrice, RadrootsCoreQuantityPriceError, RadrootsCoreUnit, - }; - use radroots_event::ids::RadrootsInventoryBinId; - use radroots_event::listing::RadrootsListingBin; - - fn bin_id(raw: &str) -> RadrootsInventoryBinId { - RadrootsInventoryBinId::parse(raw).expect("bin id") - } - - fn valid_bin() -> RadrootsListingBin { - RadrootsListingBin { - bin_id: bin_id("bin-1"), - quantity: RadrootsCoreQuantity::new( - RadrootsCoreDecimal::from(2u32), - RadrootsCoreUnit::MassG, - ), - price_per_canonical_unit: RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new(RadrootsCoreDecimal::from(5u32), RadrootsCoreCurrency::USD), - RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::MassG), - ), - display_amount: None, - display_unit: None, - display_label: None, - display_price: None, - display_price_unit: None, - } - } - - #[test] - fn try_subtotal_for_rejects_unit_mismatch() { - let bin = RadrootsListingBin { - bin_id: bin_id("bin-1"), - quantity: RadrootsCoreQuantity::new( - RadrootsCoreDecimal::from(1u32), - RadrootsCoreUnit::MassG, - ), - price_per_canonical_unit: RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new(RadrootsCoreDecimal::from(10u32), RadrootsCoreCurrency::USD), - RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::Each), - ), - display_amount: None, - display_unit: None, - display_label: None, - display_price: None, - display_price_unit: None, - }; - - let err = bin.try_subtotal_for_count(1).unwrap_err(); - assert_eq!( - err, - RadrootsCoreQuantityPriceError::UnitMismatch { - have: RadrootsCoreUnit::MassG, - want: RadrootsCoreUnit::Each, - } - ); - } - - #[test] - fn subtotal_and_total_for_count_follow_effective_quantity() { - let bin = valid_bin(); - let subtotal = bin.subtotal_for_count(3); - let total = bin.total_for_count(3); - - assert_eq!(subtotal.quantity_amount, RadrootsCoreDecimal::from(6u32)); - assert_eq!(subtotal.quantity_unit, RadrootsCoreUnit::MassG); - assert_eq!( - subtotal.price_amount.amount, - RadrootsCoreDecimal::from(30u32) - ); - assert_eq!(subtotal.price_currency, RadrootsCoreCurrency::USD); - - assert_eq!(total.quantity_amount, subtotal.quantity_amount); - assert_eq!(total.quantity_unit, subtotal.quantity_unit); - assert_eq!(total.price_amount, subtotal.price_amount); - assert_eq!(total.price_currency, subtotal.price_currency); - } - - #[test] - fn try_subtotal_and_try_total_match_non_fallible_paths() { - let bin = valid_bin(); - let subtotal = bin.try_subtotal_for_count(4).expect("subtotal"); - let total = bin.try_total_for_count(4).expect("total"); - - assert_eq!(subtotal.quantity_amount, RadrootsCoreDecimal::from(8u32)); - assert_eq!( - subtotal.price_amount.amount, - RadrootsCoreDecimal::from(40u32) - ); - assert_eq!(total.quantity_amount, subtotal.quantity_amount); - assert_eq!(total.price_amount, subtotal.price_amount); - } - - #[test] - fn try_total_for_count_propagates_subtotal_errors() { - let bin = RadrootsListingBin { - bin_id: bin_id("bin-1"), - quantity: RadrootsCoreQuantity::new( - RadrootsCoreDecimal::from(1u32), - RadrootsCoreUnit::MassG, - ), - price_per_canonical_unit: RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new(RadrootsCoreDecimal::from(10u32), RadrootsCoreCurrency::USD), - RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::Each), - ), - display_amount: None, - display_unit: None, - display_label: None, - display_price: None, - display_price_unit: None, - }; - - let err = bin.try_total_for_count(1).unwrap_err(); - assert_eq!( - err, - RadrootsCoreQuantityPriceError::UnitMismatch { - have: RadrootsCoreUnit::MassG, - want: RadrootsCoreUnit::Each, - } - ); - } -} diff --git a/crates/trade/src/listing/validation.rs b/crates/trade/src/listing/validation.rs @@ -1,706 +0,0 @@ -#![forbid(unsafe_code)] - -#[cfg(not(feature = "std"))] -use alloc::{format, string::String}; - -use radroots_core::{ - RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreUnit, -}; -use radroots_event::{ - RadrootsEventEnvelope, - ids::RadrootsListingAddress, - kinds::is_listing_kind, - listing::{ - RadrootsListing, RadrootsListingAvailability, RadrootsListingDeliveryMethod, - RadrootsListingPublicLocation, - }, - location::{has_textual_locality, is_public_geohash5}, - trade_validation::RadrootsTradeValidationListingError as TradeListingValidationError, -}; - -use radroots_event_codec::listing::decode::listing_from_nostr_event; - -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Clone, Debug)] -pub struct RadrootsTradeListing { - pub listing_id: String, - pub listing_addr: String, - pub seller_pubkey: String, - pub title: String, - pub description: String, - pub product_type: String, - pub primary_bin_id: String, - pub bin_quantity: RadrootsCoreQuantity, - pub unit: RadrootsCoreUnit, - pub unit_price: RadrootsCoreMoney, - pub inventory_available: RadrootsCoreDecimal, - pub availability: RadrootsListingAvailability, - pub location: RadrootsListingPublicLocation, - pub delivery_method: RadrootsListingDeliveryMethod, - pub listing: RadrootsListing, -} - -pub fn validate_listing_event( - event: &RadrootsEventEnvelope, -) -> Result<RadrootsTradeListing, TradeListingValidationError> { - if !is_listing_kind(event.kind_u32()) { - return Err(TradeListingValidationError::InvalidKind { - kind: event.kind_u32(), - }); - } - - let listing = listing_from_nostr_event(event) - .map_err(|error| TradeListingValidationError::ParseError { error })?; - let listing_id = listing.d_tag.trim().to_string(); - - let seller_pubkey = event.author_str().to_owned(); - if listing.farm.pubkey != seller_pubkey { - return Err(TradeListingValidationError::InvalidSeller); - } - let listing_addr_raw = format!("{}:{}:{}", event.kind_u32(), seller_pubkey, listing_id); - let listing_addr = RadrootsListingAddress::parse(&listing_addr_raw) - .expect("validated listing identity must form a listing address") - .into_string(); - - let title = listing.product.title.trim().to_string(); - if title.is_empty() { - return Err(TradeListingValidationError::MissingTitle); - } - - let description = listing - .product - .summary - .as_ref() - .map(|s| s.trim().to_string()) - .unwrap_or_default(); - if description.is_empty() { - return Err(TradeListingValidationError::MissingDescription); - } - - let product_type = if !listing.product.category.trim().is_empty() { - listing.product.category.trim().to_string() - } else { - listing.product.key.trim().to_string() - }; - if product_type.is_empty() { - return Err(TradeListingValidationError::MissingProductType); - } - - if listing.bins.is_empty() { - return Err(TradeListingValidationError::MissingBins); - } - let primary_bin_id = listing.primary_bin_id.trim().to_string(); - let primary_bin = listing - .bins - .iter() - .find(|bin| bin.bin_id == primary_bin_id) - .ok_or(TradeListingValidationError::MissingPrimaryBin)?; - - if primary_bin.quantity.amount.is_sign_negative() { - return Err(TradeListingValidationError::InvalidBin); - } - if !primary_bin.quantity.is_canonical() { - return Err(TradeListingValidationError::InvalidBin); - } - if !primary_bin - .price_per_canonical_unit - .is_price_per_canonical_unit() - { - return Err(TradeListingValidationError::InvalidPrice); - } - if primary_bin - .price_per_canonical_unit - .amount - .amount - .is_sign_negative() - { - return Err(TradeListingValidationError::InvalidPrice); - } - if primary_bin.price_per_canonical_unit.quantity.unit != primary_bin.quantity.unit { - return Err(TradeListingValidationError::InvalidPrice); - } - - let inventory_available = listing - .inventory_available - .ok_or(TradeListingValidationError::MissingInventory)?; - if inventory_available.is_sign_negative() { - return Err(TradeListingValidationError::InvalidInventory); - } - - let availability = listing - .availability - .clone() - .ok_or(TradeListingValidationError::MissingAvailability)?; - let location = listing - .location - .clone() - .ok_or(TradeListingValidationError::MissingLocation)?; - if !has_textual_locality( - &location.primary, - location.city.as_deref(), - location.region.as_deref(), - location.country.as_deref(), - ) { - return Err(TradeListingValidationError::MissingLocationLocality); - } - validate_listing_location_geohash(&location.geohash)?; - let delivery_method = listing - .delivery_method - .clone() - .ok_or(TradeListingValidationError::MissingDeliveryMethod)?; - - Ok(RadrootsTradeListing { - listing_id, - listing_addr, - seller_pubkey, - title, - description, - product_type, - primary_bin_id: primary_bin_id.clone(), - bin_quantity: primary_bin.quantity.clone(), - unit: primary_bin.quantity.unit, - unit_price: primary_bin.price_per_canonical_unit.amount.clone(), - inventory_available, - availability, - location, - delivery_method, - listing, - }) -} - -#[cfg_attr(coverage_nightly, coverage(off))] -fn validate_listing_location_geohash(geohash: &str) -> Result<(), TradeListingValidationError> { - if geohash.trim().is_empty() { - return Err(TradeListingValidationError::MissingLocationGeohash); - } - if !is_public_geohash5(geohash) { - return Err(TradeListingValidationError::InvalidLocationGeohash); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::{TradeListingValidationError, validate_listing_event}; - use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, - RadrootsCoreQuantityPrice, RadrootsCoreUnit, - }; - use radroots_event::{ - RadrootsEventEnvelope, - farm::RadrootsFarmRef, - ids::{RadrootsDTag, RadrootsInventoryBinId}, - kinds::KIND_LISTING, - listing::{ - RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, - RadrootsListingDeliveryMethod, RadrootsListingProduct, RadrootsListingPublicLocation, - }, - }; - - const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - const OTHER_SELLER: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; - - fn d_tag(raw: &str) -> RadrootsDTag { - RadrootsDTag::parse(raw).expect("d tag") - } - - fn bin_id(raw: &str) -> RadrootsInventoryBinId { - RadrootsInventoryBinId::parse(raw).expect("bin id") - } - - fn base_listing() -> RadrootsListing { - RadrootsListing { - d_tag: d_tag("AAAAAAAAAAAAAAAAAAAAAg"), - published_at: None, - farm: RadrootsFarmRef { - pubkey: SELLER.into(), - d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(), - }, - product: RadrootsListingProduct { - key: "coffee".into(), - title: "Coffee".into(), - category: "coffee".into(), - summary: Some("Single origin coffee".into()), - process: None, - lot: None, - location: None, - profile: None, - year: None, - }, - primary_bin_id: bin_id("bin-1"), - bins: vec![RadrootsListingBin { - bin_id: bin_id("bin-1"), - quantity: RadrootsCoreQuantity::new( - RadrootsCoreDecimal::from(1000u32), - RadrootsCoreUnit::MassG, - ), - price_per_canonical_unit: RadrootsCoreQuantityPrice { - amount: RadrootsCoreMoney::new( - RadrootsCoreDecimal::from(20u32), - RadrootsCoreCurrency::USD, - ), - quantity: RadrootsCoreQuantity::new( - RadrootsCoreDecimal::from(1u32), - RadrootsCoreUnit::MassG, - ), - }, - display_amount: None, - display_unit: None, - display_label: None, - display_price: None, - display_price_unit: None, - }], - resource_area: None, - plot: None, - discounts: None, - inventory_available: Some(RadrootsCoreDecimal::from(5u32)), - availability: Some(RadrootsListingAvailability::Status { - status: radroots_event::listing::RadrootsListingStatus::Active, - }), - delivery_method: Some(RadrootsListingDeliveryMethod::Pickup), - location: Some(RadrootsListingPublicLocation { - primary: "Farm".into(), - city: Some("Town".into()), - region: Some("Region".into()), - country: Some("US".into()), - geohash: "9q8yy".into(), - }), - images: None, - } - } - - fn base_event(listing: &RadrootsListing) -> RadrootsEventEnvelope { - let mut tags = vec![ - vec!["d".into(), listing.d_tag.to_string()], - vec!["p".into(), listing.farm.pubkey.clone()], - vec![ - "a".into(), - format!("30340:{}:{}", listing.farm.pubkey, listing.farm.d_tag), - ], - vec!["key".into(), listing.product.key.clone()], - vec!["title".into(), listing.product.title.clone()], - vec!["category".into(), listing.product.category.clone()], - vec![ - "summary".into(), - listing.product.summary.clone().unwrap_or_default(), - ], - vec![ - "radroots:primary_bin".into(), - listing.primary_bin_id.to_string(), - ], - ]; - for bin in &listing.bins { - tags.push(vec![ - "radroots:bin".into(), - bin.bin_id.to_string(), - bin.quantity.amount.to_string(), - bin.quantity.unit.code().to_string(), - ]); - tags.push(vec![ - "radroots:price".into(), - bin.bin_id.to_string(), - bin.price_per_canonical_unit.amount.amount.to_string(), - bin.price_per_canonical_unit - .amount - .currency - .as_str() - .to_string(), - bin.price_per_canonical_unit.quantity.amount.to_string(), - bin.price_per_canonical_unit - .quantity - .unit - .code() - .to_string(), - ]); - } - if let Some(inventory) = listing.inventory_available { - tags.push(vec!["inventory".into(), inventory.to_string()]); - } - if let Some(availability) = &listing.availability { - match availability { - RadrootsListingAvailability::Status { status } => tags.push(vec![ - "status".into(), - match status { - radroots_event::listing::RadrootsListingStatus::Active => "active".into(), - radroots_event::listing::RadrootsListingStatus::Sold => "sold".into(), - radroots_event::listing::RadrootsListingStatus::Other { value } => { - value.clone() - } - }, - ]), - RadrootsListingAvailability::Window { start, end } => { - if let Some(start) = start { - tags.push(vec![ - "radroots:availability_start".into(), - start.to_string(), - ]); - } - if let Some(end) = end { - tags.push(vec!["expires_at".into(), end.to_string()]); - } - } - } - } - if let Some(delivery) = &listing.delivery_method { - let mut tag = vec!["delivery".into()]; - match delivery { - RadrootsListingDeliveryMethod::Pickup => tag.push("pickup".into()), - RadrootsListingDeliveryMethod::LocalDelivery => tag.push("local_delivery".into()), - RadrootsListingDeliveryMethod::Shipping => tag.push("shipping".into()), - RadrootsListingDeliveryMethod::Other { method } => { - tag.push("other".into()); - tag.push(method.clone()); - } - } - tags.push(tag); - } - if let Some(location) = &listing.location { - tags.push(vec![ - "location".into(), - location.primary.clone(), - location.city.clone().unwrap_or_default(), - location.region.clone().unwrap_or_default(), - location.country.clone().unwrap_or_default(), - ]); - tags.push(vec!["g".into(), location.geohash.clone()]); - } - - event_with_parts(SELLER, KIND_LISTING, tags, String::new()) - } - - fn event_with_parts( - author: &str, - kind: u32, - tags: Vec<Vec<String>>, - content: String, - ) -> RadrootsEventEnvelope { - RadrootsEventEnvelope::new(radroots_event::RadrootsEventEnvelopeParts { - id: "9".repeat(64), - author: author.to_string(), - created_at: 0, - kind, - tags, - content, - sig: "f".repeat(128), - }) - .expect("event") - } - - fn assert_validation_err(listing: RadrootsListing, expected: TradeListingValidationError) { - let event = base_event(&listing); - let err = validate_listing_event(&event).unwrap_err(); - assert_eq!(format!("{err}"), format!("{expected}")); - } - - #[test] - fn validate_listing_ok() { - let listing = base_listing(); - let event = base_event(&listing); - assert!(validate_listing_event(&event).is_ok()); - } - - #[test] - fn validate_listing_rejects_retired_kind() { - let listing = base_listing(); - let event = event_with_parts( - SELLER, - 30403, - base_event(&listing).tags_as_vec(), - String::new(), - ); - let err = validate_listing_event(&event).unwrap_err(); - assert_eq!( - err, - TradeListingValidationError::InvalidKind { kind: 30403 } - ); - } - - #[test] - fn validate_listing_rejects_missing_d_tag() { - let event = event_with_parts(SELLER, KIND_LISTING, Vec::new(), String::new()); - let err = validate_listing_event(&event).unwrap_err(); - assert_eq!( - err, - TradeListingValidationError::ParseError { - error: crate::listing::ListingParseError::MissingTag("d".to_string()) - } - ); - } - - #[test] - fn validate_listing_rejects_invalid_currency() { - let event = event_with_parts( - SELLER, - KIND_LISTING, - vec![ - vec!["d".into(), "AAAAAAAAAAAAAAAAAAAAAg".into()], - vec!["p".into(), SELLER.into()], - vec!["a".into(), format!("30340:{SELLER}:AAAAAAAAAAAAAAAAAAAAAA")], - vec!["key".into(), "coffee".into()], - vec!["title".into(), "Coffee".into()], - vec!["category".into(), "coffee".into()], - vec!["summary".into(), "Single origin".into()], - vec![ - "quantity".into(), - "1".into(), - "lb".into(), - "bag".into(), - "5".into(), - ], - vec![ - "price".into(), - "20".into(), - "US".into(), - "1".into(), - "lb".into(), - ], - vec![ - "location".into(), - "Farm".into(), - "Town".into(), - "Region".into(), - ], - vec!["status".into(), "active".into()], - vec!["delivery".into(), "pickup".into()], - ], - String::new(), - ); - let err = validate_listing_event(&event).unwrap_err(); - assert!(format!("{err:?}").starts_with("ParseError")); - } - - #[test] - fn validate_listing_rejects_mismatched_seller() { - let listing = base_listing(); - let event = event_with_parts( - OTHER_SELLER, - KIND_LISTING, - base_event(&listing).tags_as_vec(), - String::new(), - ); - let err = validate_listing_event(&event).unwrap_err(); - assert_eq!(err, TradeListingValidationError::InvalidSeller); - } - - #[test] - fn validate_listing_rejects_missing_inventory() { - let mut listing = base_listing(); - listing.inventory_available = None; - let event = base_event(&listing); - let err = validate_listing_event(&event).unwrap_err(); - assert_eq!(err, TradeListingValidationError::MissingInventory); - } - - #[test] - fn validate_listing_rejects_invalid_kind() { - let listing = base_listing(); - let event = event_with_parts(SELLER, 0, base_event(&listing).tags_as_vec(), String::new()); - let err = validate_listing_event(&event).unwrap_err(); - assert_eq!(err, TradeListingValidationError::InvalidKind { kind: 0 }); - } - - #[test] - fn validate_listing_rejects_missing_title() { - let mut listing = base_listing(); - listing.product.title = " ".into(); - assert_validation_err(listing, TradeListingValidationError::MissingTitle); - } - - #[test] - fn validate_listing_rejects_missing_description() { - let mut listing = base_listing(); - listing.product.summary = Some(" ".into()); - assert_validation_err(listing, TradeListingValidationError::MissingDescription); - } - - #[test] - fn validate_listing_rejects_missing_product_type() { - let mut listing = base_listing(); - listing.product.category = " ".into(); - listing.product.key = " ".into(); - assert_validation_err(listing, TradeListingValidationError::MissingProductType); - } - - #[test] - fn validate_listing_rejects_missing_bins() { - let mut listing = base_listing(); - listing.bins.clear(); - assert_validation_err( - listing, - TradeListingValidationError::ParseError { - error: crate::listing::ListingParseError::InvalidTag("radroots:primary_bin".into()), - }, - ); - } - - #[test] - fn validate_listing_rejects_missing_primary_bin_id() { - assert!(RadrootsInventoryBinId::parse(" ").is_err()); - } - - #[test] - fn validate_listing_rejects_primary_bin_not_found() { - let mut listing = base_listing(); - listing.primary_bin_id = bin_id("missing"); - assert_validation_err( - listing, - TradeListingValidationError::ParseError { - error: crate::listing::ListingParseError::InvalidTag("radroots:primary_bin".into()), - }, - ); - } - - #[test] - fn validate_listing_rejects_negative_quantity() { - let mut listing = base_listing(); - listing.bins[0].quantity.amount = "-1".parse().unwrap(); - assert_validation_err(listing, TradeListingValidationError::InvalidBin); - } - - #[test] - fn validate_listing_rejects_non_canonical_quantity() { - let mut listing = base_listing(); - listing.bins[0].quantity.unit = RadrootsCoreUnit::MassKg; - assert_validation_err( - listing, - TradeListingValidationError::ParseError { - error: crate::listing::ListingParseError::InvalidTag("radroots:bin".into()), - }, - ); - } - - #[test] - fn validate_listing_rejects_non_canonical_price_quantity() { - let mut listing = base_listing(); - listing.bins[0].price_per_canonical_unit.quantity.unit = RadrootsCoreUnit::MassKg; - assert_validation_err( - listing, - TradeListingValidationError::ParseError { - error: crate::listing::ListingParseError::InvalidTag("radroots:price".into()), - }, - ); - } - - #[test] - fn validate_listing_rejects_negative_price_amount() { - let mut listing = base_listing(); - listing.bins[0].price_per_canonical_unit.amount.amount = "-1".parse().unwrap(); - assert_validation_err(listing, TradeListingValidationError::InvalidPrice); - } - - #[test] - fn validate_listing_rejects_price_unit_mismatch() { - let mut listing = base_listing(); - listing.bins[0].price_per_canonical_unit.quantity.unit = RadrootsCoreUnit::Each; - assert_validation_err( - listing, - TradeListingValidationError::ParseError { - error: crate::listing::ListingParseError::InvalidTag("radroots:price".into()), - }, - ); - } - - #[test] - fn validate_listing_rejects_negative_inventory() { - let mut listing = base_listing(); - listing.inventory_available = Some("-1".parse().unwrap()); - assert_validation_err(listing, TradeListingValidationError::InvalidInventory); - } - - #[test] - fn validate_listing_rejects_missing_availability() { - let mut listing = base_listing(); - listing.availability = None; - assert_validation_err(listing, TradeListingValidationError::MissingAvailability); - } - - #[test] - fn validate_listing_rejects_missing_location() { - let mut listing = base_listing(); - listing.location = None; - assert_validation_err(listing, TradeListingValidationError::MissingLocation); - } - - #[test] - fn validate_listing_rejects_missing_location_locality() { - let mut listing = base_listing(); - let location = listing.location.as_mut().expect("location"); - location.city = None; - location.region = None; - location.country = None; - assert_validation_err( - listing, - TradeListingValidationError::MissingLocationLocality, - ); - } - - #[test] - fn validate_listing_rejects_missing_location_geohash() { - let mut listing = base_listing(); - listing.location.as_mut().expect("location").geohash = " ".into(); - assert_validation_err( - listing, - TradeListingValidationError::ParseError { - error: crate::listing::ListingParseError::InvalidTag("g".to_string()), - }, - ); - } - - #[test] - fn validate_listing_rejects_invalid_location_geohash() { - let mut listing = base_listing(); - listing.location.as_mut().expect("location").geohash = "9q8yyz".into(); - assert_validation_err( - listing, - TradeListingValidationError::ParseError { - error: crate::listing::ListingParseError::InvalidTag("g".to_string()), - }, - ); - } - - #[test] - fn validate_listing_rejects_missing_delivery_method() { - let mut listing = base_listing(); - listing.delivery_method = None; - assert_validation_err(listing, TradeListingValidationError::MissingDeliveryMethod); - } - - #[test] - fn validation_error_display_covers_all_variants() { - let errors = vec![ - TradeListingValidationError::InvalidKind { kind: 9 }, - TradeListingValidationError::MissingListingId, - TradeListingValidationError::ListingEventNotFound { - listing_addr: "addr".into(), - }, - TradeListingValidationError::ListingEventFetchFailed { - listing_addr: "addr".into(), - }, - TradeListingValidationError::ParseError { - error: crate::listing::ListingParseError::InvalidTag("d".into()), - }, - TradeListingValidationError::InvalidSeller, - TradeListingValidationError::MissingFarmProfile, - TradeListingValidationError::MissingFarmRecord, - TradeListingValidationError::MissingTitle, - TradeListingValidationError::MissingDescription, - TradeListingValidationError::MissingProductType, - TradeListingValidationError::MissingBins, - TradeListingValidationError::MissingPrimaryBin, - TradeListingValidationError::InvalidBin, - TradeListingValidationError::MissingPrice, - TradeListingValidationError::InvalidPrice, - TradeListingValidationError::MissingInventory, - TradeListingValidationError::InvalidInventory, - TradeListingValidationError::MissingAvailability, - TradeListingValidationError::MissingLocation, - TradeListingValidationError::MissingLocationLocality, - TradeListingValidationError::MissingLocationGeohash, - TradeListingValidationError::InvalidLocationGeohash, - TradeListingValidationError::MissingDeliveryMethod, - ]; - for error in errors { - assert!(!error.to_string().trim().is_empty()); - } - } -} diff --git a/crates/trade/src/operational_listing/draft.rs b/crates/trade/src/operational_listing/draft.rs @@ -0,0 +1,517 @@ +//! Canonicalization for Radroots Listing v1 drafts. + +#![forbid(unsafe_code)] + +use core::fmt; + +#[cfg(not(feature = "std"))] +use alloc::{format, string::ToString, vec::Vec}; + +#[cfg(feature = "std")] +use std::{string::ToString, vec::Vec}; + +use radroots_authority::RadrootsActorContext; +use radroots_event::{ + contract::RadrootsActorRole, + ids::{ + RadrootsClassifiedListingAddress, RadrootsIdParseError, RadrootsInventoryBinId, + RadrootsPublicKey, + }, + kinds::KIND_CLASSIFIED_LISTING, + operational_listing::RadrootsOperationalListing, +}; + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug)] +pub struct RadrootsOperationalListingEditDocumentV1 { + pub listing: RadrootsOperationalListing, +} + +impl RadrootsOperationalListingEditDocumentV1 { + pub fn new(listing: RadrootsOperationalListing) -> Self { + Self { listing } + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[derive(Clone, Debug)] +pub struct RadrootsOperationalListingCanonicalEdit { + listing: RadrootsOperationalListing, + seller_pubkey: RadrootsPublicKey, + public_listing_addr: RadrootsClassifiedListingAddress, +} + +impl RadrootsOperationalListingCanonicalEdit { + pub fn new( + mut listing: RadrootsOperationalListing, + seller_pubkey: RadrootsPublicKey, + ) -> Result<Self, RadrootsOperationalListingEditError> { + let farm_pubkey = RadrootsPublicKey::parse(listing.farm.pubkey.as_str()) + .map_err(RadrootsOperationalListingEditError::InvalidFarmPubkey)?; + if farm_pubkey != seller_pubkey { + return Err(RadrootsOperationalListingEditError::FarmPubkeyMismatch { + expected_pubkey: seller_pubkey, + actual_pubkey: farm_pubkey, + }); + } + listing.farm.pubkey = farm_pubkey.as_str().to_string(); + validate_listing_bins(&listing)?; + + let public_listing_addr = listing_addr( + KIND_CLASSIFIED_LISTING, + &seller_pubkey, + listing.d_tag.as_str(), + ); + + Ok(Self { + listing, + seller_pubkey, + public_listing_addr, + }) + } + + pub fn listing(&self) -> &RadrootsOperationalListing { + &self.listing + } + + pub fn seller_pubkey(&self) -> &RadrootsPublicKey { + &self.seller_pubkey + } + + pub fn public_listing_addr(&self) -> &RadrootsClassifiedListingAddress { + &self.public_listing_addr + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RadrootsOperationalListingEditError { + InvalidFarmPubkey(RadrootsIdParseError), + InvalidClassifiedListingAddress(RadrootsIdParseError), + ActorRoleUnsatisfied { + required_role: RadrootsActorRole, + }, + FarmPubkeyMismatch { + expected_pubkey: RadrootsPublicKey, + actual_pubkey: RadrootsPublicKey, + }, + MissingPrimaryBin { + primary_bin_id: RadrootsInventoryBinId, + }, + DuplicateBinId { + bin_id: RadrootsInventoryBinId, + }, +} + +impl fmt::Display for RadrootsOperationalListingEditError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidFarmPubkey(error) => { + write!(f, "invalid listing edit farm pubkey: {error}") + } + Self::InvalidClassifiedListingAddress(error) => { + write!(f, "invalid listing edit address: {error}") + } + Self::ActorRoleUnsatisfied { required_role } => write!( + f, + "listing edit actor does not satisfy required role {required_role:?}" + ), + Self::FarmPubkeyMismatch { .. } => { + f.write_str("listing edit farm pubkey does not match seller") + } + Self::MissingPrimaryBin { .. } => f.write_str("listing edit primary bin is missing"), + Self::DuplicateBinId { .. } => f.write_str("listing edit contains duplicate bin ID"), + } + } +} + +impl core::error::Error for RadrootsOperationalListingEditError {} + +fn validate_listing_bins( + listing: &RadrootsOperationalListing, +) -> Result<(), RadrootsOperationalListingEditError> { + let primary_bin_id = listing.primary_bin_id.clone(); + let mut seen_bin_ids = Vec::new(); + let mut primary_bin_found = false; + for bin in &listing.bins { + if seen_bin_ids + .iter() + .any(|seen_bin_id| seen_bin_id == &bin.bin_id) + { + return Err(RadrootsOperationalListingEditError::DuplicateBinId { + bin_id: bin.bin_id.clone(), + }); + } + if bin.bin_id == primary_bin_id { + primary_bin_found = true; + } + seen_bin_ids.push(bin.bin_id.clone()); + } + + if !primary_bin_found { + return Err(RadrootsOperationalListingEditError::MissingPrimaryBin { primary_bin_id }); + } + Ok(()) +} + +fn listing_addr( + kind: u32, + seller_pubkey: &RadrootsPublicKey, + d_tag: &str, +) -> RadrootsClassifiedListingAddress { + RadrootsClassifiedListingAddress::parse(format!("{kind}:{}:{d_tag}", seller_pubkey.as_str())) + .expect("typed listing identity must form a listing address") +} + +pub fn canonicalize_operational_listing_edit( + actor: &RadrootsActorContext, + mut document: RadrootsOperationalListingEditDocumentV1, +) -> Result<RadrootsOperationalListingCanonicalEdit, RadrootsOperationalListingEditError> { + if !actor.satisfies(RadrootsActorRole::Seller) { + return Err(RadrootsOperationalListingEditError::ActorRoleUnsatisfied { + required_role: RadrootsActorRole::Seller, + }); + } + + let seller_pubkey = actor.pubkey().clone(); + let farm_pubkey = document.listing.farm.pubkey.as_str(); + if farm_pubkey.is_empty() { + document.listing.farm.pubkey = seller_pubkey.as_str().to_string(); + } + + RadrootsOperationalListingCanonicalEdit::new(document.listing, seller_pubkey) +} + +#[cfg(test)] +mod tests { + use radroots_authority::RadrootsActorContext; + use radroots_core::{ + RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, + RadrootsCoreQuantityPrice, RadrootsCoreUnit, + }; + use radroots_event::{ + contract::RadrootsActorRole, + farm::RadrootsFarmRef, + ids::{ + RadrootsClassifiedListingAddress, RadrootsDTag, RadrootsInventoryBinId, + RadrootsPublicKey, + }, + kinds::KIND_CLASSIFIED_LISTING, + operational_listing::{ + RadrootsOperationalListing, RadrootsOperationalListingBin, + RadrootsOperationalListingProduct, + }, + }; + + use super::{ + RadrootsOperationalListingCanonicalEdit, RadrootsOperationalListingEditDocumentV1, + RadrootsOperationalListingEditError, canonicalize_operational_listing_edit, + }; + + const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const OTHER: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + fn d_tag(raw: &str) -> RadrootsDTag { + RadrootsDTag::parse(raw).expect("d tag") + } + + fn bin_id(raw: &str) -> RadrootsInventoryBinId { + RadrootsInventoryBinId::parse(raw).expect("bin id") + } + + fn listing() -> RadrootsOperationalListing { + RadrootsOperationalListing { + d_tag: d_tag("AAAAAAAAAAAAAAAAAAAAAg"), + published_at: None, + farm: RadrootsFarmRef { + pubkey: SELLER.to_string(), + d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), + }, + product: RadrootsOperationalListingProduct { + key: "coffee".to_string(), + title: "Coffee".to_string(), + category: "coffee".to_string(), + summary: Some("Single origin coffee".to_string()), + process: None, + lot: None, + location: None, + profile: None, + year: None, + }, + primary_bin_id: bin_id("bin-1"), + bins: vec![RadrootsOperationalListingBin { + bin_id: bin_id("bin-1"), + quantity: RadrootsCoreQuantity::new( + RadrootsCoreDecimal::from(1000u32), + RadrootsCoreUnit::MassG, + ), + price_per_canonical_unit: RadrootsCoreQuantityPrice { + amount: RadrootsCoreMoney::new( + RadrootsCoreDecimal::from(20u32), + RadrootsCoreCurrency::USD, + ), + quantity: RadrootsCoreQuantity::new( + RadrootsCoreDecimal::from(1u32), + RadrootsCoreUnit::MassG, + ), + }, + display_amount: None, + display_unit: None, + display_label: None, + display_price: None, + display_price_unit: None, + }], + resource_area: None, + plot: None, + discounts: None, + inventory_available: None, + availability: None, + delivery_method: None, + location: None, + images: None, + } + } + + fn seller_actor() -> RadrootsActorContext { + RadrootsActorContext::explicit_pubkey(SELLER, [RadrootsActorRole::Seller]).expect("actor") + } + + fn buyer_actor() -> RadrootsActorContext { + RadrootsActorContext::explicit_pubkey(SELLER, [RadrootsActorRole::Buyer]).expect("actor") + } + + #[test] + fn draft_document_wraps_listing() { + let document = RadrootsOperationalListingEditDocumentV1::new(listing()); + + assert_eq!(document.listing.d_tag.as_str(), "AAAAAAAAAAAAAAAAAAAAAg"); + assert_eq!(document.listing.product.title, "Coffee"); + } + + #[cfg(feature = "serde_json")] + #[test] + fn draft_document_deserializes_as_untrusted_input() { + let json = serde_json::to_string(&RadrootsOperationalListingEditDocumentV1::new(listing())) + .expect("serialize document"); + + let document: RadrootsOperationalListingEditDocumentV1 = + serde_json::from_str(&json).expect("deserialize document"); + let canonical = canonicalize_operational_listing_edit(&seller_actor(), document) + .expect("canonical draft"); + + assert_eq!(canonical.seller_pubkey().as_str(), SELLER); + assert_eq!(canonical.listing().product.title, "Coffee"); + } + + #[test] + fn canonical_draft_carries_seller_listing_and_addresses() { + let seller_pubkey = RadrootsPublicKey::parse(SELLER).expect("seller"); + let listing = listing(); + + let canonical = + RadrootsOperationalListingCanonicalEdit::new(listing, seller_pubkey.clone()) + .expect("canonical"); + + assert_eq!(canonical.seller_pubkey(), &seller_pubkey); + assert_eq!( + canonical.public_listing_addr().as_str(), + format!("{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg") + ); + assert_eq!(canonical.listing().d_tag.as_str(), "AAAAAAAAAAAAAAAAAAAAAg"); + } + + #[test] + fn listing_edit_error_variants_are_precise() { + assert!(matches!( + RadrootsOperationalListingEditError::InvalidFarmPubkey( + RadrootsPublicKey::parse("bad").unwrap_err() + ), + RadrootsOperationalListingEditError::InvalidFarmPubkey(_) + )); + assert!(matches!( + RadrootsOperationalListingEditError::InvalidClassifiedListingAddress( + RadrootsClassifiedListingAddress::parse("bad").unwrap_err() + ), + RadrootsOperationalListingEditError::InvalidClassifiedListingAddress(_) + )); + } + + #[test] + fn canonicalize_operational_listing_edit_fills_missing_farm_pubkey_and_derives_address() { + let mut listing = listing(); + listing.farm.pubkey.clear(); + let document = RadrootsOperationalListingEditDocumentV1::new(listing); + + let canonical = canonicalize_operational_listing_edit(&seller_actor(), document) + .expect("canonical draft"); + + assert_eq!(canonical.seller_pubkey().as_str(), SELLER); + assert_eq!( + canonical.public_listing_addr().as_str(), + format!("{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg") + ); + assert_eq!(canonical.listing().farm.pubkey, SELLER); + } + + #[test] + fn canonicalize_operational_listing_edit_rejects_non_seller_actor() { + let document = RadrootsOperationalListingEditDocumentV1::new(listing()); + + let error = canonicalize_operational_listing_edit(&buyer_actor(), document).unwrap_err(); + + assert_eq!( + error, + RadrootsOperationalListingEditError::ActorRoleUnsatisfied { + required_role: RadrootsActorRole::Seller + } + ); + } + + #[test] + fn canonicalize_operational_listing_edit_rejects_mismatched_farm_pubkey() { + let mut listing = listing(); + listing.farm.pubkey = OTHER.to_string(); + let document = RadrootsOperationalListingEditDocumentV1::new(listing); + + let error = canonicalize_operational_listing_edit(&seller_actor(), document).unwrap_err(); + + assert!(matches!( + error, + RadrootsOperationalListingEditError::FarmPubkeyMismatch { .. } + )); + } + + #[test] + fn canonicalize_operational_listing_edit_rejects_invalid_farm_pubkey() { + let mut listing = listing(); + listing.farm.pubkey = "bad".to_string(); + let document = RadrootsOperationalListingEditDocumentV1::new(listing); + + let error = canonicalize_operational_listing_edit(&seller_actor(), document).unwrap_err(); + + assert!(matches!( + error, + RadrootsOperationalListingEditError::InvalidFarmPubkey(_) + )); + } + + #[test] + fn canonical_draft_new_rejects_mismatched_farm_pubkey() { + let mut listing = listing(); + listing.farm.pubkey = OTHER.to_string(); + + let error = RadrootsOperationalListingCanonicalEdit::new( + listing, + RadrootsPublicKey::parse(SELLER).expect("seller"), + ) + .unwrap_err(); + + assert!(matches!( + error, + RadrootsOperationalListingEditError::FarmPubkeyMismatch { .. } + )); + } + + #[test] + fn canonical_draft_new_rejects_invalid_farm_pubkey() { + let mut listing = listing(); + listing.farm.pubkey = "bad".to_string(); + + let error = RadrootsOperationalListingCanonicalEdit::new( + listing, + RadrootsPublicKey::parse(SELLER).expect("seller"), + ) + .unwrap_err(); + + assert!(matches!( + error, + RadrootsOperationalListingEditError::InvalidFarmPubkey(_) + )); + } + + #[test] + fn canonical_draft_new_rejects_empty_farm_pubkey() { + let mut listing = listing(); + listing.farm.pubkey.clear(); + + let error = RadrootsOperationalListingCanonicalEdit::new( + listing, + RadrootsPublicKey::parse(SELLER).expect("seller"), + ) + .unwrap_err(); + + assert!(matches!( + error, + RadrootsOperationalListingEditError::InvalidFarmPubkey(_) + )); + } + + #[test] + fn canonicalize_operational_listing_edit_rejects_missing_primary_bin() { + let mut listing = listing(); + listing.primary_bin_id = bin_id("bin-2"); + let document = RadrootsOperationalListingEditDocumentV1::new(listing); + + let error = canonicalize_operational_listing_edit(&seller_actor(), document).unwrap_err(); + + assert_eq!( + error, + RadrootsOperationalListingEditError::MissingPrimaryBin { + primary_bin_id: bin_id("bin-2") + } + ); + } + + #[test] + fn canonical_draft_new_rejects_missing_primary_bin() { + let mut listing = listing(); + listing.primary_bin_id = bin_id("bin-2"); + + let error = RadrootsOperationalListingCanonicalEdit::new( + listing, + RadrootsPublicKey::parse(SELLER).expect("seller"), + ) + .unwrap_err(); + + assert_eq!( + error, + RadrootsOperationalListingEditError::MissingPrimaryBin { + primary_bin_id: bin_id("bin-2") + } + ); + } + + #[test] + fn canonicalize_operational_listing_edit_rejects_duplicate_bin_ids() { + let mut listing = listing(); + listing.bins.push(listing.bins[0].clone()); + let document = RadrootsOperationalListingEditDocumentV1::new(listing); + + let error = canonicalize_operational_listing_edit(&seller_actor(), document).unwrap_err(); + + assert_eq!( + error, + RadrootsOperationalListingEditError::DuplicateBinId { + bin_id: bin_id("bin-1") + } + ); + } + + #[test] + fn canonical_draft_new_rejects_duplicate_bin_ids() { + let mut listing = listing(); + listing.bins.push(listing.bins[0].clone()); + + let error = RadrootsOperationalListingCanonicalEdit::new( + listing, + RadrootsPublicKey::parse(SELLER).expect("seller"), + ) + .unwrap_err(); + + assert_eq!( + error, + RadrootsOperationalListingEditError::DuplicateBinId { + bin_id: bin_id("bin-1") + } + ); + } +} diff --git a/crates/trade/src/operational_listing/mod.rs b/crates/trade/src/operational_listing/mod.rs @@ -0,0 +1,251 @@ +pub mod draft; +pub mod model; +pub mod mutation; +pub mod price_ext; +pub mod validation; + +use radroots_event::{ + RadrootsEventEnvelope, + ids::{ + RadrootsAddressableCoordinateParts, RadrootsClassifiedListingAddress, RadrootsDTag, + RadrootsIdParseError, RadrootsPublicKey, + }, + operational_listing::{RadrootsOperationalListing, RadrootsOperationalListingParseError}, +}; +use radroots_event_codec::operational_listing::decode::operational_listing_from_nostr_event; + +pub use self::draft::{ + RadrootsOperationalListingCanonicalEdit, RadrootsOperationalListingEditDocumentV1, + RadrootsOperationalListingEditError, canonicalize_operational_listing_edit, +}; +#[cfg(feature = "serde_json")] +pub use self::mutation::build_operational_listing_mutation_draft; +pub use self::mutation::{ + RadrootsOperationalListingLifecycleState, RadrootsOperationalListingMutation, + RadrootsOperationalListingMutationError, +}; +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsClassifiedListingAddressParts { + pub address: RadrootsClassifiedListingAddress, + pub kind: u32, + pub seller_pubkey: RadrootsPublicKey, + pub listing_id: RadrootsDTag, +} + +impl RadrootsClassifiedListingAddressParts { + pub fn parse(value: impl AsRef<str>) -> Result<Self, RadrootsIdParseError> { + parse_classified_listing_address(value) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsPublicClassifiedListingAddress { + pub address: RadrootsClassifiedListingAddress, + pub kind: u32, + pub seller_pubkey: RadrootsPublicKey, + pub listing_id: RadrootsDTag, +} + +impl RadrootsPublicClassifiedListingAddress { + pub fn parse(value: impl AsRef<str>) -> Result<Self, RadrootsIdParseError> { + parse_public_classified_listing_address(value) + } +} + +pub fn parse_classified_listing_address( + value: impl AsRef<str>, +) -> Result<RadrootsClassifiedListingAddressParts, RadrootsIdParseError> { + let value = value.as_ref(); + let address = RadrootsClassifiedListingAddress::parse(value)?; + let parts = RadrootsAddressableCoordinateParts::parse(address.as_str()) + .expect("typed listing address must contain valid coordinate parts"); + Ok(RadrootsClassifiedListingAddressParts { + address, + kind: parts.kind, + seller_pubkey: parts.pubkey, + listing_id: parts.d_tag, + }) +} + +pub fn parse_public_classified_listing_address( + value: impl AsRef<str>, +) -> Result<RadrootsPublicClassifiedListingAddress, RadrootsIdParseError> { + let parts = parse_classified_listing_address(value)?; + Ok(RadrootsPublicClassifiedListingAddress { + address: parts.address, + kind: parts.kind, + seller_pubkey: parts.seller_pubkey, + listing_id: parts.listing_id, + }) +} + +pub fn parse_operational_listing_event( + event: &RadrootsEventEnvelope, +) -> Result<RadrootsOperationalListing, RadrootsOperationalListingParseError> { + operational_listing_from_nostr_event(event) +} + +#[cfg(test)] +mod tests { + use super::{ + RadrootsClassifiedListingAddressParts, RadrootsPublicClassifiedListingAddress, + parse_classified_listing_address, parse_operational_listing_event, + parse_public_classified_listing_address, + }; + use radroots_event::{ + RadrootsEventEnvelope, RadrootsEventEnvelopeParts, + ids::RadrootsClassifiedListingAddress, + kinds::{KIND_CLASSIFIED_LISTING, KIND_PROFILE}, + operational_listing::RadrootsOperationalListingParseError, + }; + + const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + fn listing_event() -> RadrootsEventEnvelope { + RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { + id: "9".repeat(64), + author: SELLER.to_string(), + created_at: 1, + kind: KIND_CLASSIFIED_LISTING, + tags: vec![ + vec!["d".into(), "AAAAAAAAAAAAAAAAAAAAAg".into()], + vec!["p".into(), SELLER.into()], + vec!["a".into(), format!("30340:{SELLER}:AAAAAAAAAAAAAAAAAAAAAA")], + vec!["key".into(), "coffee".into()], + vec!["title".into(), "Coffee".into()], + vec!["category".into(), "coffee".into()], + vec!["summary".into(), "Single origin".into()], + vec!["radroots:primary_bin".into(), "bin-1".into()], + vec![ + "radroots:bin".into(), + "bin-1".into(), + "1000".into(), + "g".into(), + ], + vec![ + "radroots:price".into(), + "bin-1".into(), + "20".into(), + "USD".into(), + "1".into(), + "g".into(), + ], + ], + content: String::new(), + sig: "f".repeat(128), + }) + .expect("listing event") + } + + #[test] + fn parse_operational_listing_event_rejects_non_listing_kind() { + let event = RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { + id: "8".repeat(64), + author: SELLER.to_string(), + created_at: 1, + kind: KIND_PROFILE, + tags: vec![], + content: String::new(), + sig: "f".repeat(128), + }) + .expect("profile event"); + + assert!(matches!( + parse_operational_listing_event(&event), + Err(RadrootsOperationalListingParseError::InvalidKind( + KIND_PROFILE + )) + )); + } + + #[test] + fn parse_operational_listing_event_accepts_listing_kind() { + let listing = parse_operational_listing_event(&listing_event()).expect("listing"); + + assert_eq!(listing.d_tag.as_str(), "AAAAAAAAAAAAAAAAAAAAAg"); + assert_eq!(listing.farm.pubkey, SELLER); + assert_eq!(listing.primary_bin_id.as_str(), "bin-1"); + } + + #[test] + fn listing_address_associated_parsers_delegate_to_public_parsers() { + let raw = format!("{KIND_CLASSIFIED_LISTING}:{SELLER}:listing-1"); + + let listing = + RadrootsClassifiedListingAddressParts::parse(raw.clone()).expect("listing address"); + let public = RadrootsPublicClassifiedListingAddress::parse(&raw).expect("public address"); + let typed = parse_public_classified_listing_address( + RadrootsClassifiedListingAddress::parse(&raw).expect("typed addr"), + ) + .expect("typed public address"); + + assert_eq!(listing.address.as_str(), raw); + assert_eq!(public.address.as_str(), raw); + assert_eq!(typed.address.as_str(), raw); + assert_eq!(listing.seller_pubkey.as_str(), SELLER); + assert_eq!(public.seller_pubkey.as_str(), SELLER); + assert_eq!(typed.seller_pubkey.as_str(), SELLER); + } + + #[test] + fn parse_public_classified_listing_address_accepts_public_listing_kind() { + let raw = format!("{KIND_CLASSIFIED_LISTING}:{SELLER}:listing-1"); + let parsed = parse_public_classified_listing_address(&raw).expect("public listing address"); + + assert_eq!(parsed.address.as_str(), raw); + assert_eq!(parsed.kind, KIND_CLASSIFIED_LISTING); + assert_eq!(parsed.seller_pubkey.as_str(), SELLER); + assert_eq!(parsed.listing_id.as_str(), "listing-1"); + } + + #[test] + fn parse_classified_listing_address_rejects_retired_listing_kind() { + let raw = format!("30403:{SELLER}:listing-1"); + + assert!(matches!( + parse_classified_listing_address(&raw), + Err(radroots_event::ids::RadrootsIdParseError::UnexpectedKind { + expected: KIND_CLASSIFIED_LISTING, + actual: 30403, + }) + )); + assert!(matches!( + parse_public_classified_listing_address(&raw), + Err(radroots_event::ids::RadrootsIdParseError::UnexpectedKind { + expected: KIND_CLASSIFIED_LISTING, + actual: 30403, + }) + )); + } + + #[test] + fn parse_public_classified_listing_address_maps_invalid_listing_addresses() { + assert!(matches!( + parse_public_classified_listing_address("not-an-address"), + Err(radroots_event::ids::RadrootsIdParseError::InvalidFormat) + )); + + let raw = format!("{KIND_PROFILE}:{SELLER}:listing-1"); + assert!(matches!( + parse_public_classified_listing_address(&raw), + Err(radroots_event::ids::RadrootsIdParseError::UnexpectedKind { + expected: KIND_CLASSIFIED_LISTING, + actual: KIND_PROFILE, + }) + )); + assert!(RadrootsClassifiedListingAddress::parse(&raw).is_err()); + } + + #[test] + fn parse_classified_listing_address_rejects_non_listing_kind() { + let raw = format!("{KIND_PROFILE}:{SELLER}:listing-1"); + + assert!(matches!( + parse_classified_listing_address(&raw), + Err(radroots_event::ids::RadrootsIdParseError::UnexpectedKind { + expected: KIND_CLASSIFIED_LISTING, + actual: KIND_PROFILE, + }) + )); + } +} diff --git a/crates/trade/src/operational_listing/model.rs b/crates/trade/src/operational_listing/model.rs @@ -0,0 +1,17 @@ +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsOperationalListingSubtotal { + pub price_amount: radroots_core::RadrootsCoreMoney, + pub price_currency: radroots_core::RadrootsCoreCurrency, + pub quantity_amount: radroots_core::RadrootsCoreDecimal, + pub quantity_unit: radroots_core::RadrootsCoreUnit, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsOperationalListingTotal { + pub price_amount: radroots_core::RadrootsCoreMoney, + pub price_currency: radroots_core::RadrootsCoreCurrency, + pub quantity_amount: radroots_core::RadrootsCoreDecimal, + pub quantity_unit: radroots_core::RadrootsCoreUnit, +} diff --git a/crates/trade/src/operational_listing/mutation.rs b/crates/trade/src/operational_listing/mutation.rs @@ -0,0 +1,502 @@ +//! Mutation draft preparation for Radroots Listing v1. + +#![forbid(unsafe_code)] + +use core::fmt; + +#[cfg(all(feature = "serde_json", not(feature = "std")))] +use alloc::string::{String, ToString}; + +#[cfg(all(feature = "serde_json", feature = "std"))] +use std::string::{String, ToString}; + +use radroots_event::ids::RadrootsClassifiedListingAddress; +#[cfg(feature = "serde_json")] +use radroots_event::{ + draft::{RadrootsDraftError, RadrootsEventDraft}, + kinds::KIND_CLASSIFIED_LISTING, +}; +#[cfg(feature = "serde_json")] +use radroots_event_codec::operational_listing::encode::to_wire_parts_with_kind; + +use crate::operational_listing::draft::RadrootsOperationalListingCanonicalEdit; + +/// Listing v1 mutation intent for draft preparation only. +/// +/// Publish and update target the public listing event, while local-only draft +/// persistence and archive are intentionally unsupported as wire events. +#[derive(Clone, Debug)] +pub enum RadrootsOperationalListingMutation { + Publish { + draft: RadrootsOperationalListingCanonicalEdit, + }, + Update { + draft: RadrootsOperationalListingCanonicalEdit, + }, + SaveDraft { + draft: RadrootsOperationalListingCanonicalEdit, + }, + Archive { + listing_addr: RadrootsClassifiedListingAddress, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RadrootsOperationalListingLifecycleState { + Draft, + Published, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RadrootsOperationalListingMutationError { + UnsupportedMutation, + #[cfg(feature = "serde_json")] + EncodeListing(String), + #[cfg(feature = "serde_json")] + FrozenDraft(RadrootsDraftError), +} + +impl fmt::Display for RadrootsOperationalListingMutationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnsupportedMutation => f.write_str("listing mutation is not supported"), + #[cfg(feature = "serde_json")] + Self::EncodeListing(error) => { + write!(f, "failed to encode listing mutation: {error}") + } + #[cfg(feature = "serde_json")] + Self::FrozenDraft(error) => { + write!(f, "failed to build listing mutation draft: {error}") + } + } + } +} + +impl core::error::Error for RadrootsOperationalListingMutationError {} + +#[cfg(feature = "serde_json")] +const OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID: &str = "radroots.operational_listing.published.v1"; + +impl RadrootsOperationalListingMutation { + pub fn publish(draft: RadrootsOperationalListingCanonicalEdit) -> Self { + Self::Publish { draft } + } + + pub fn update(draft: RadrootsOperationalListingCanonicalEdit) -> Self { + Self::Update { draft } + } + + pub fn save_draft(draft: RadrootsOperationalListingCanonicalEdit) -> Self { + Self::SaveDraft { draft } + } + + pub fn archive(listing_addr: RadrootsClassifiedListingAddress) -> Self { + Self::Archive { listing_addr } + } + + pub fn lifecycle_state( + &self, + ) -> Result<RadrootsOperationalListingLifecycleState, RadrootsOperationalListingMutationError> + { + match self { + Self::Publish { .. } | Self::Update { .. } => { + Ok(RadrootsOperationalListingLifecycleState::Published) + } + Self::SaveDraft { .. } => Ok(RadrootsOperationalListingLifecycleState::Draft), + Self::Archive { .. } => { + Err(RadrootsOperationalListingMutationError::UnsupportedMutation) + } + } + } + + pub fn canonical_draft( + &self, + ) -> Result<&RadrootsOperationalListingCanonicalEdit, RadrootsOperationalListingMutationError> + { + match self { + Self::Publish { draft } | Self::Update { draft } | Self::SaveDraft { draft } => { + Ok(draft) + } + Self::Archive { .. } => { + Err(RadrootsOperationalListingMutationError::UnsupportedMutation) + } + } + } + + pub fn listing_addr( + &self, + ) -> Result<&RadrootsClassifiedListingAddress, RadrootsOperationalListingMutationError> { + match self { + Self::Publish { draft } | Self::Update { draft } => Ok(draft.public_listing_addr()), + Self::SaveDraft { draft } => Ok(draft.public_listing_addr()), + Self::Archive { .. } => { + Err(RadrootsOperationalListingMutationError::UnsupportedMutation) + } + } + } +} + +#[cfg(feature = "serde_json")] +pub fn build_operational_listing_mutation_draft( + mutation: &RadrootsOperationalListingMutation, + created_at: u64, +) -> Result<RadrootsEventDraft, RadrootsOperationalListingMutationError> { + let (draft, kind, contract_id) = match mutation { + RadrootsOperationalListingMutation::Publish { draft } + | RadrootsOperationalListingMutation::Update { draft } => ( + draft, + KIND_CLASSIFIED_LISTING, + OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID, + ), + RadrootsOperationalListingMutation::SaveDraft { .. } + | RadrootsOperationalListingMutation::Archive { .. } => { + return Err(RadrootsOperationalListingMutationError::UnsupportedMutation); + } + }; + let parts = to_wire_parts_with_kind(draft.listing(), kind).map_err(|error| { + RadrootsOperationalListingMutationError::EncodeListing(error.to_string()) + })?; + RadrootsEventDraft::new( + contract_id, + parts.kind, + created_at, + parts.tags, + parts.content, + draft.seller_pubkey().as_str(), + ) + .map_err(RadrootsOperationalListingMutationError::FrozenDraft) +} + +#[cfg(test)] +mod tests { + use radroots_core::{ + RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, + RadrootsCoreQuantityPrice, RadrootsCoreUnit, + }; + use radroots_event::{ + RadrootsEventEnvelope, RadrootsEventEnvelopeParts, + contract::validate_event_contract_shape, + farm::RadrootsFarmRef, + ids::{ + RadrootsClassifiedListingAddress, RadrootsDTag, RadrootsInventoryBinId, + RadrootsPublicKey, + }, + kinds::KIND_CLASSIFIED_LISTING, + operational_listing::{ + RadrootsOperationalListing, RadrootsOperationalListingAvailability, + RadrootsOperationalListingBin, RadrootsOperationalListingDeliveryMethod, + RadrootsOperationalListingProduct, RadrootsOperationalListingPublicLocation, + RadrootsOperationalListingStatus, + }, + resource_area::RadrootsResourceAreaRef, + }; + + use crate::operational_listing::draft::RadrootsOperationalListingCanonicalEdit; + use crate::operational_listing::validation::validate_operational_listing_event; + + use super::{ + OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID, RadrootsOperationalListingLifecycleState, + RadrootsOperationalListingMutation, RadrootsOperationalListingMutationError, + build_operational_listing_mutation_draft, + }; + + const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + fn d_tag(raw: &str) -> RadrootsDTag { + RadrootsDTag::parse(raw).expect("d tag") + } + + fn bin_id(raw: &str) -> RadrootsInventoryBinId { + RadrootsInventoryBinId::parse(raw).expect("bin id") + } + + fn listing() -> RadrootsOperationalListing { + RadrootsOperationalListing { + d_tag: d_tag("AAAAAAAAAAAAAAAAAAAAAg"), + published_at: None, + farm: RadrootsFarmRef { + pubkey: SELLER.to_string(), + d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), + }, + product: RadrootsOperationalListingProduct { + key: "coffee".to_string(), + title: "Coffee".to_string(), + category: "coffee".to_string(), + summary: Some("Single origin coffee".to_string()), + process: None, + lot: None, + location: None, + profile: None, + year: None, + }, + primary_bin_id: bin_id("bin-1"), + bins: vec![RadrootsOperationalListingBin { + bin_id: bin_id("bin-1"), + quantity: RadrootsCoreQuantity::new( + RadrootsCoreDecimal::from(1000u32), + RadrootsCoreUnit::MassG, + ), + price_per_canonical_unit: RadrootsCoreQuantityPrice { + amount: RadrootsCoreMoney::new( + RadrootsCoreDecimal::from(20u32), + RadrootsCoreCurrency::USD, + ), + quantity: RadrootsCoreQuantity::new( + RadrootsCoreDecimal::from(1u32), + RadrootsCoreUnit::MassG, + ), + }, + display_amount: None, + display_unit: None, + display_label: None, + display_price: None, + display_price_unit: None, + }], + resource_area: None, + plot: None, + discounts: None, + inventory_available: Some(RadrootsCoreDecimal::from(5u32)), + availability: Some(RadrootsOperationalListingAvailability::Status { + status: RadrootsOperationalListingStatus::Active, + }), + delivery_method: Some(RadrootsOperationalListingDeliveryMethod::Pickup), + location: Some(RadrootsOperationalListingPublicLocation { + primary: "Farm".to_string(), + city: Some("Town".to_string()), + region: Some("Region".to_string()), + country: Some("US".to_string()), + geohash: "9q8yy".to_string(), + }), + images: None, + } + } + + fn canonical_draft() -> RadrootsOperationalListingCanonicalEdit { + RadrootsOperationalListingCanonicalEdit::new( + listing(), + RadrootsPublicKey::parse(SELLER).expect("seller"), + ) + .expect("canonical listing edit") + } + + #[test] + fn supported_mutations_report_lifecycle_states() { + assert_eq!( + RadrootsOperationalListingMutation::publish(canonical_draft()) + .lifecycle_state() + .expect("state"), + RadrootsOperationalListingLifecycleState::Published + ); + assert_eq!( + RadrootsOperationalListingMutation::update(canonical_draft()) + .lifecycle_state() + .expect("state"), + RadrootsOperationalListingLifecycleState::Published + ); + assert_eq!( + RadrootsOperationalListingMutation::save_draft(canonical_draft()) + .lifecycle_state() + .expect("state"), + RadrootsOperationalListingLifecycleState::Draft + ); + } + + #[test] + fn supported_mutations_expose_canonical_drafts() { + let publish = RadrootsOperationalListingMutation::publish(canonical_draft()); + let update = RadrootsOperationalListingMutation::update(canonical_draft()); + let save_draft = RadrootsOperationalListingMutation::save_draft(canonical_draft()); + + assert_eq!( + publish + .canonical_draft() + .expect("draft") + .seller_pubkey() + .as_str(), + SELLER + ); + assert_eq!( + update + .canonical_draft() + .expect("draft") + .seller_pubkey() + .as_str(), + SELLER + ); + assert_eq!( + save_draft + .canonical_draft() + .expect("draft") + .seller_pubkey() + .as_str(), + SELLER + ); + assert_eq!( + publish + .canonical_draft() + .expect("draft") + .listing() + .d_tag + .as_str(), + "AAAAAAAAAAAAAAAAAAAAAg" + ); + } + + #[test] + fn supported_mutations_report_listing_addresses() { + let publish = RadrootsOperationalListingMutation::publish(canonical_draft()); + let update = RadrootsOperationalListingMutation::update(canonical_draft()); + let save_draft = RadrootsOperationalListingMutation::save_draft(canonical_draft()); + + assert_eq!( + publish.listing_addr().expect("address").as_str(), + format!("{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg") + ); + assert_eq!( + update.listing_addr().expect("address").as_str(), + format!("{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg") + ); + assert_eq!( + save_draft.listing_addr().expect("address").as_str(), + format!("{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg") + ); + } + + #[test] + fn archive_is_explicitly_unsupported() { + let archive = RadrootsOperationalListingMutation::archive( + RadrootsClassifiedListingAddress::parse(format!( + "{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg" + )) + .expect("listing address"), + ); + + assert_eq!( + archive.lifecycle_state().unwrap_err(), + RadrootsOperationalListingMutationError::UnsupportedMutation + ); + assert_eq!( + archive.canonical_draft().unwrap_err(), + RadrootsOperationalListingMutationError::UnsupportedMutation + ); + assert_eq!( + archive.listing_addr().unwrap_err(), + RadrootsOperationalListingMutationError::UnsupportedMutation + ); + } + + #[test] + fn build_operational_listing_mutation_draft_maps_publish_and_update_to_published_listing() { + let publish = RadrootsOperationalListingMutation::publish(canonical_draft()); + let update = RadrootsOperationalListingMutation::update(canonical_draft()); + + let publish_draft = + build_operational_listing_mutation_draft(&publish, 1_700_000_000).expect("draft"); + let update_draft = + build_operational_listing_mutation_draft(&update, 1_700_000_000).expect("draft"); + + assert_eq!(publish_draft.kind_u32(), KIND_CLASSIFIED_LISTING); + assert_eq!( + publish_draft.contract_id(), + OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID + ); + assert_eq!(publish_draft.expected_pubkey_str(), SELLER); + assert_eq!(publish_draft.created_at_u64(), 1_700_000_000); + assert_eq!(publish_draft.content(), "# Coffee\n\nSingle origin coffee"); + assert_eq!(update_draft.kind_u32(), KIND_CLASSIFIED_LISTING); + assert_eq!( + update_draft.contract_id(), + OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID + ); + assert_eq!(update_draft.expected_pubkey_str(), SELLER); + } + + #[test] + fn build_operational_listing_mutation_draft_rejects_save_draft() { + let save_draft = RadrootsOperationalListingMutation::save_draft(canonical_draft()); + + assert_eq!( + build_operational_listing_mutation_draft(&save_draft, 1_700_000_000).unwrap_err(), + RadrootsOperationalListingMutationError::UnsupportedMutation + ); + } + + #[test] + fn build_operational_listing_mutation_draft_rejects_archive() { + let archive = RadrootsOperationalListingMutation::archive( + RadrootsClassifiedListingAddress::parse(format!( + "{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg" + )) + .expect("listing address"), + ); + + assert_eq!( + build_operational_listing_mutation_draft(&archive, 1_700_000_000).unwrap_err(), + RadrootsOperationalListingMutationError::UnsupportedMutation + ); + } + + #[test] + fn build_operational_listing_mutation_draft_reports_encode_errors() { + let mut listing = listing(); + listing.resource_area = Some(RadrootsResourceAreaRef { + pubkey: SELLER.to_string(), + d_tag: "bad d tag".to_string(), + }); + let draft = RadrootsOperationalListingCanonicalEdit::new( + listing, + RadrootsPublicKey::parse(SELLER).expect("seller"), + ) + .expect("canonical listing edit"); + let publish = RadrootsOperationalListingMutation::publish(draft); + + let err = build_operational_listing_mutation_draft(&publish, 1_700_000_000).unwrap_err(); + + assert!(matches!( + err, + RadrootsOperationalListingMutationError::EncodeListing(_) + )); + } + + #[test] + fn build_operational_listing_mutation_draft_event_id_is_stable_for_fixed_input() { + let publish = RadrootsOperationalListingMutation::publish(canonical_draft()); + + let first = + build_operational_listing_mutation_draft(&publish, 1_700_000_000).expect("draft"); + let second = + build_operational_listing_mutation_draft(&publish, 1_700_000_000).expect("draft"); + + assert_eq!( + first.expected_event_id_str(), + second.expected_event_id_str() + ); + assert_eq!(first.expected_event_id_str().len(), 64); + assert_eq!(first.tags_as_vec(), second.tags_as_vec()); + assert_eq!(first.content(), second.content()); + } + + #[test] + fn build_operational_listing_mutation_draft_output_validates_as_operational_listing() { + let publish = RadrootsOperationalListingMutation::publish(canonical_draft()); + let draft = + build_operational_listing_mutation_draft(&publish, 1_700_000_000).expect("draft"); + + let event = RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { + id: draft.expected_event_id_str().to_owned(), + author: draft.expected_pubkey_str().to_owned(), + created_at: draft.created_at_u64(), + kind: draft.kind_u32(), + tags: draft.tags_as_vec(), + content: draft.content().to_owned(), + sig: "f".repeat(128), + }) + .expect("listing event"); + validate_event_contract_shape(&event, OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID) + .expect("operational listing contract"); + let validated = validate_operational_listing_event(&event).expect("validated listing"); + + assert_eq!(validated.seller_pubkey, SELLER); + assert!(validated.listing_addr.contains(&format!(":{SELLER}:"))); + } +} diff --git a/crates/trade/src/operational_listing/price_ext.rs b/crates/trade/src/operational_listing/price_ext.rs @@ -0,0 +1,217 @@ +use crate::operational_listing::model::{ + RadrootsOperationalListingSubtotal, RadrootsOperationalListingTotal, +}; +use radroots_core::{ + RadrootsCoreDecimal, RadrootsCoreQuantity, RadrootsCoreQuantityPriceError, + RadrootsCoreQuantityPriceOps, +}; +use radroots_event::operational_listing::RadrootsOperationalListingBin; + +pub trait BinPricingExt { + fn subtotal_for_count(&self, bin_count: u32) -> RadrootsOperationalListingSubtotal; + fn total_for_count(&self, bin_count: u32) -> RadrootsOperationalListingTotal; +} + +pub trait BinPricingTryExt { + fn try_subtotal_for_count( + &self, + bin_count: u32, + ) -> Result<RadrootsOperationalListingSubtotal, RadrootsCoreQuantityPriceError>; + fn try_total_for_count( + &self, + bin_count: u32, + ) -> Result<RadrootsOperationalListingTotal, RadrootsCoreQuantityPriceError>; +} + +#[inline] +fn effective_quantity(bin: &RadrootsOperationalListingBin, bin_count: u32) -> RadrootsCoreQuantity { + let amount = bin.quantity.amount * RadrootsCoreDecimal::from(bin_count); + RadrootsCoreQuantity::new(amount, bin.quantity.unit) +} + +impl BinPricingExt for RadrootsOperationalListingBin { + fn subtotal_for_count(&self, bin_count: u32) -> RadrootsOperationalListingSubtotal { + let effective_qty = effective_quantity(self, bin_count); + let money = self + .price_per_canonical_unit + .cost_for_rounded(&effective_qty); + let currency = money.currency; + + RadrootsOperationalListingSubtotal { + price_amount: money, + price_currency: currency, + quantity_amount: effective_qty.amount, + quantity_unit: effective_qty.unit, + } + } + + fn total_for_count(&self, bin_count: u32) -> RadrootsOperationalListingTotal { + let sub = self.subtotal_for_count(bin_count); + RadrootsOperationalListingTotal { + price_amount: sub.price_amount, + price_currency: sub.price_currency, + quantity_amount: sub.quantity_amount, + quantity_unit: sub.quantity_unit, + } + } +} + +impl BinPricingTryExt for RadrootsOperationalListingBin { + fn try_subtotal_for_count( + &self, + bin_count: u32, + ) -> Result<RadrootsOperationalListingSubtotal, RadrootsCoreQuantityPriceError> { + let effective_qty = effective_quantity(self, bin_count); + let money = self + .price_per_canonical_unit + .try_cost_for_rounded(&effective_qty)?; + let currency = money.currency; + + Ok(RadrootsOperationalListingSubtotal { + price_amount: money, + price_currency: currency, + quantity_amount: effective_qty.amount, + quantity_unit: effective_qty.unit, + }) + } + + fn try_total_for_count( + &self, + bin_count: u32, + ) -> Result<RadrootsOperationalListingTotal, RadrootsCoreQuantityPriceError> { + let sub = self.try_subtotal_for_count(bin_count)?; + Ok(RadrootsOperationalListingTotal { + price_amount: sub.price_amount, + price_currency: sub.price_currency, + quantity_amount: sub.quantity_amount, + quantity_unit: sub.quantity_unit, + }) + } +} + +#[cfg(test)] +mod tests { + use super::{BinPricingExt, BinPricingTryExt}; + use radroots_core::{ + RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, + RadrootsCoreQuantityPrice, RadrootsCoreQuantityPriceError, RadrootsCoreUnit, + }; + use radroots_event::ids::RadrootsInventoryBinId; + use radroots_event::operational_listing::RadrootsOperationalListingBin; + + fn bin_id(raw: &str) -> RadrootsInventoryBinId { + RadrootsInventoryBinId::parse(raw).expect("bin id") + } + + fn valid_bin() -> RadrootsOperationalListingBin { + RadrootsOperationalListingBin { + bin_id: bin_id("bin-1"), + quantity: RadrootsCoreQuantity::new( + RadrootsCoreDecimal::from(2u32), + RadrootsCoreUnit::MassG, + ), + price_per_canonical_unit: RadrootsCoreQuantityPrice::new( + RadrootsCoreMoney::new(RadrootsCoreDecimal::from(5u32), RadrootsCoreCurrency::USD), + RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::MassG), + ), + display_amount: None, + display_unit: None, + display_label: None, + display_price: None, + display_price_unit: None, + } + } + + #[test] + fn try_subtotal_for_rejects_unit_mismatch() { + let bin = RadrootsOperationalListingBin { + bin_id: bin_id("bin-1"), + quantity: RadrootsCoreQuantity::new( + RadrootsCoreDecimal::from(1u32), + RadrootsCoreUnit::MassG, + ), + price_per_canonical_unit: RadrootsCoreQuantityPrice::new( + RadrootsCoreMoney::new(RadrootsCoreDecimal::from(10u32), RadrootsCoreCurrency::USD), + RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::Each), + ), + display_amount: None, + display_unit: None, + display_label: None, + display_price: None, + display_price_unit: None, + }; + + let err = bin.try_subtotal_for_count(1).unwrap_err(); + assert_eq!( + err, + RadrootsCoreQuantityPriceError::UnitMismatch { + have: RadrootsCoreUnit::MassG, + want: RadrootsCoreUnit::Each, + } + ); + } + + #[test] + fn subtotal_and_total_for_count_follow_effective_quantity() { + let bin = valid_bin(); + let subtotal = bin.subtotal_for_count(3); + let total = bin.total_for_count(3); + + assert_eq!(subtotal.quantity_amount, RadrootsCoreDecimal::from(6u32)); + assert_eq!(subtotal.quantity_unit, RadrootsCoreUnit::MassG); + assert_eq!( + subtotal.price_amount.amount, + RadrootsCoreDecimal::from(30u32) + ); + assert_eq!(subtotal.price_currency, RadrootsCoreCurrency::USD); + + assert_eq!(total.quantity_amount, subtotal.quantity_amount); + assert_eq!(total.quantity_unit, subtotal.quantity_unit); + assert_eq!(total.price_amount, subtotal.price_amount); + assert_eq!(total.price_currency, subtotal.price_currency); + } + + #[test] + fn try_subtotal_and_try_total_match_non_fallible_paths() { + let bin = valid_bin(); + let subtotal = bin.try_subtotal_for_count(4).expect("subtotal"); + let total = bin.try_total_for_count(4).expect("total"); + + assert_eq!(subtotal.quantity_amount, RadrootsCoreDecimal::from(8u32)); + assert_eq!( + subtotal.price_amount.amount, + RadrootsCoreDecimal::from(40u32) + ); + assert_eq!(total.quantity_amount, subtotal.quantity_amount); + assert_eq!(total.price_amount, subtotal.price_amount); + } + + #[test] + fn try_total_for_count_propagates_subtotal_errors() { + let bin = RadrootsOperationalListingBin { + bin_id: bin_id("bin-1"), + quantity: RadrootsCoreQuantity::new( + RadrootsCoreDecimal::from(1u32), + RadrootsCoreUnit::MassG, + ), + price_per_canonical_unit: RadrootsCoreQuantityPrice::new( + RadrootsCoreMoney::new(RadrootsCoreDecimal::from(10u32), RadrootsCoreCurrency::USD), + RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::Each), + ), + display_amount: None, + display_unit: None, + display_label: None, + display_price: None, + display_price_unit: None, + }; + + let err = bin.try_total_for_count(1).unwrap_err(); + assert_eq!( + err, + RadrootsCoreQuantityPriceError::UnitMismatch { + have: RadrootsCoreUnit::MassG, + want: RadrootsCoreUnit::Each, + } + ); + } +} diff --git a/crates/trade/src/operational_listing/validation.rs b/crates/trade/src/operational_listing/validation.rs @@ -0,0 +1,732 @@ +#![forbid(unsafe_code)] + +#[cfg(not(feature = "std"))] +use alloc::{ + format, + string::{String, ToString}, +}; + +use radroots_core::{ + RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreUnit, +}; +use radroots_event::{ + RadrootsEventEnvelope, + ids::RadrootsClassifiedListingAddress, + kinds::is_classified_listing_kind, + location::{has_textual_locality, is_public_geohash5}, + operational_listing::{ + RadrootsOperationalListing, RadrootsOperationalListingAvailability, + RadrootsOperationalListingDeliveryMethod, RadrootsOperationalListingPublicLocation, + }, + trade_validation::RadrootsOperationalListingValidationError as OperationalListingValidationError, +}; + +use radroots_event_codec::operational_listing::decode::operational_listing_from_nostr_event; + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug)] +pub struct RadrootsOperationalListingTradeProjection { + pub listing_id: String, + pub listing_addr: RadrootsClassifiedListingAddress, + pub seller_pubkey: String, + pub title: String, + pub description: String, + pub product_type: String, + pub primary_bin_id: String, + pub bin_quantity: RadrootsCoreQuantity, + pub unit: RadrootsCoreUnit, + pub unit_price: RadrootsCoreMoney, + pub inventory_available: RadrootsCoreDecimal, + pub availability: RadrootsOperationalListingAvailability, + pub location: RadrootsOperationalListingPublicLocation, + pub delivery_method: RadrootsOperationalListingDeliveryMethod, + pub listing: RadrootsOperationalListing, +} + +pub fn validate_operational_listing_event( + event: &RadrootsEventEnvelope, +) -> Result<RadrootsOperationalListingTradeProjection, OperationalListingValidationError> { + if !is_classified_listing_kind(event.kind_u32()) { + return Err(OperationalListingValidationError::InvalidKind { + kind: event.kind_u32(), + }); + } + + let listing = operational_listing_from_nostr_event(event) + .map_err(|error| OperationalListingValidationError::ParseError { error })?; + let listing_id = listing.d_tag.trim().to_string(); + + let seller_pubkey = event.author_str().to_string(); + if listing.farm.pubkey != seller_pubkey { + return Err(OperationalListingValidationError::InvalidSeller); + } + let listing_addr_raw = format!("{}:{}:{}", event.kind_u32(), seller_pubkey, listing_id); + let listing_addr = RadrootsClassifiedListingAddress::parse(&listing_addr_raw) + .expect("validated listing identity must form a listing address"); + + let title = listing.product.title.trim().to_string(); + if title.is_empty() { + return Err(OperationalListingValidationError::MissingTitle); + } + + let description = listing + .product + .summary + .as_ref() + .map(|s| s.trim().to_string()) + .unwrap_or_default(); + if description.is_empty() { + return Err(OperationalListingValidationError::MissingDescription); + } + + let product_type = if !listing.product.category.trim().is_empty() { + listing.product.category.trim().to_string() + } else { + listing.product.key.trim().to_string() + }; + if product_type.is_empty() { + return Err(OperationalListingValidationError::MissingProductType); + } + + if listing.bins.is_empty() { + return Err(OperationalListingValidationError::MissingBins); + } + let primary_bin_id = listing.primary_bin_id.trim().to_string(); + let primary_bin = listing + .bins + .iter() + .find(|bin| bin.bin_id == primary_bin_id) + .ok_or(OperationalListingValidationError::MissingPrimaryBin)?; + + if primary_bin.quantity.amount.is_sign_negative() { + return Err(OperationalListingValidationError::InvalidBin); + } + if !primary_bin.quantity.is_canonical() { + return Err(OperationalListingValidationError::InvalidBin); + } + if !primary_bin + .price_per_canonical_unit + .is_price_per_canonical_unit() + { + return Err(OperationalListingValidationError::InvalidPrice); + } + if primary_bin + .price_per_canonical_unit + .amount + .amount + .is_sign_negative() + { + return Err(OperationalListingValidationError::InvalidPrice); + } + if primary_bin.price_per_canonical_unit.quantity.unit != primary_bin.quantity.unit { + return Err(OperationalListingValidationError::InvalidPrice); + } + + let inventory_available = listing + .inventory_available + .ok_or(OperationalListingValidationError::MissingInventory)?; + if inventory_available.is_sign_negative() { + return Err(OperationalListingValidationError::InvalidInventory); + } + + let availability = listing + .availability + .clone() + .ok_or(OperationalListingValidationError::MissingAvailability)?; + let location = listing + .location + .clone() + .ok_or(OperationalListingValidationError::MissingLocation)?; + if !has_textual_locality( + &location.primary, + location.city.as_deref(), + location.region.as_deref(), + location.country.as_deref(), + ) { + return Err(OperationalListingValidationError::MissingLocationLocality); + } + validate_listing_location_geohash(&location.geohash)?; + let delivery_method = listing + .delivery_method + .clone() + .ok_or(OperationalListingValidationError::MissingDeliveryMethod)?; + + Ok(RadrootsOperationalListingTradeProjection { + listing_id, + listing_addr, + seller_pubkey, + title, + description, + product_type, + primary_bin_id: primary_bin_id.clone(), + bin_quantity: primary_bin.quantity.clone(), + unit: primary_bin.quantity.unit, + unit_price: primary_bin.price_per_canonical_unit.amount.clone(), + inventory_available, + availability, + location, + delivery_method, + listing, + }) +} + +#[cfg_attr(coverage_nightly, coverage(off))] +fn validate_listing_location_geohash( + geohash: &str, +) -> Result<(), OperationalListingValidationError> { + if geohash.trim().is_empty() { + return Err(OperationalListingValidationError::MissingLocationGeohash); + } + if !is_public_geohash5(geohash) { + return Err(OperationalListingValidationError::InvalidLocationGeohash); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{OperationalListingValidationError, validate_operational_listing_event}; + use radroots_core::{ + RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, + RadrootsCoreQuantityPrice, RadrootsCoreUnit, + }; + use radroots_event::{ + RadrootsEventEnvelope, + farm::RadrootsFarmRef, + ids::{RadrootsDTag, RadrootsInventoryBinId}, + kinds::KIND_CLASSIFIED_LISTING, + operational_listing::{ + RadrootsOperationalListing, RadrootsOperationalListingAvailability, + RadrootsOperationalListingBin, RadrootsOperationalListingDeliveryMethod, + RadrootsOperationalListingProduct, RadrootsOperationalListingPublicLocation, + }, + }; + + const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const OTHER_SELLER: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + fn d_tag(raw: &str) -> RadrootsDTag { + RadrootsDTag::parse(raw).expect("d tag") + } + + fn bin_id(raw: &str) -> RadrootsInventoryBinId { + RadrootsInventoryBinId::parse(raw).expect("bin id") + } + + fn base_listing() -> RadrootsOperationalListing { + RadrootsOperationalListing { + d_tag: d_tag("AAAAAAAAAAAAAAAAAAAAAg"), + published_at: None, + farm: RadrootsFarmRef { + pubkey: SELLER.into(), + d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(), + }, + product: RadrootsOperationalListingProduct { + key: "coffee".into(), + title: "Coffee".into(), + category: "coffee".into(), + summary: Some("Single origin coffee".into()), + process: None, + lot: None, + location: None, + profile: None, + year: None, + }, + primary_bin_id: bin_id("bin-1"), + bins: vec![RadrootsOperationalListingBin { + bin_id: bin_id("bin-1"), + quantity: RadrootsCoreQuantity::new( + RadrootsCoreDecimal::from(1000u32), + RadrootsCoreUnit::MassG, + ), + price_per_canonical_unit: RadrootsCoreQuantityPrice { + amount: RadrootsCoreMoney::new( + RadrootsCoreDecimal::from(20u32), + RadrootsCoreCurrency::USD, + ), + quantity: RadrootsCoreQuantity::new( + RadrootsCoreDecimal::from(1u32), + RadrootsCoreUnit::MassG, + ), + }, + display_amount: None, + display_unit: None, + display_label: None, + display_price: None, + display_price_unit: None, + }], + resource_area: None, + plot: None, + discounts: None, + inventory_available: Some(RadrootsCoreDecimal::from(5u32)), + availability: Some(RadrootsOperationalListingAvailability::Status { + status: + radroots_event::operational_listing::RadrootsOperationalListingStatus::Active, + }), + delivery_method: Some(RadrootsOperationalListingDeliveryMethod::Pickup), + location: Some(RadrootsOperationalListingPublicLocation { + primary: "Farm".into(), + city: Some("Town".into()), + region: Some("Region".into()), + country: Some("US".into()), + geohash: "9q8yy".into(), + }), + images: None, + } + } + + fn base_event(listing: &RadrootsOperationalListing) -> RadrootsEventEnvelope { + let mut tags = vec![ + vec!["d".into(), listing.d_tag.to_string()], + vec!["p".into(), listing.farm.pubkey.clone()], + vec![ + "a".into(), + format!("30340:{}:{}", listing.farm.pubkey, listing.farm.d_tag), + ], + vec!["key".into(), listing.product.key.clone()], + vec!["title".into(), listing.product.title.clone()], + vec!["category".into(), listing.product.category.clone()], + vec![ + "summary".into(), + listing.product.summary.clone().unwrap_or_default(), + ], + vec![ + "radroots:primary_bin".into(), + listing.primary_bin_id.to_string(), + ], + ]; + for bin in &listing.bins { + tags.push(vec![ + "radroots:bin".into(), + bin.bin_id.to_string(), + bin.quantity.amount.to_string(), + bin.quantity.unit.code().to_string(), + ]); + tags.push(vec![ + "radroots:price".into(), + bin.bin_id.to_string(), + bin.price_per_canonical_unit.amount.amount.to_string(), + bin.price_per_canonical_unit + .amount + .currency + .as_str() + .to_string(), + bin.price_per_canonical_unit.quantity.amount.to_string(), + bin.price_per_canonical_unit + .quantity + .unit + .code() + .to_string(), + ]); + } + if let Some(inventory) = listing.inventory_available { + tags.push(vec!["inventory".into(), inventory.to_string()]); + } + if let Some(availability) = &listing.availability { + match availability { + RadrootsOperationalListingAvailability::Status { status } => tags.push(vec![ + "status".into(), + match status { + radroots_event::operational_listing::RadrootsOperationalListingStatus::Active => "active".into(), + radroots_event::operational_listing::RadrootsOperationalListingStatus::Sold => "sold".into(), + radroots_event::operational_listing::RadrootsOperationalListingStatus::Other { value } => { + value.clone() + } + }, + ]), + RadrootsOperationalListingAvailability::Window { start, end } => { + if let Some(start) = start { + tags.push(vec![ + "radroots:availability_start".into(), + start.to_string(), + ]); + } + if let Some(end) = end { + tags.push(vec!["expires_at".into(), end.to_string()]); + } + } + } + } + if let Some(delivery) = &listing.delivery_method { + let mut tag = vec!["delivery".into()]; + match delivery { + RadrootsOperationalListingDeliveryMethod::Pickup => tag.push("pickup".into()), + RadrootsOperationalListingDeliveryMethod::LocalDelivery => { + tag.push("local_delivery".into()) + } + RadrootsOperationalListingDeliveryMethod::Shipping => tag.push("shipping".into()), + RadrootsOperationalListingDeliveryMethod::Other { method } => { + tag.push("other".into()); + tag.push(method.clone()); + } + } + tags.push(tag); + } + if let Some(location) = &listing.location { + tags.push(vec![ + "location".into(), + location.primary.clone(), + location.city.clone().unwrap_or_default(), + location.region.clone().unwrap_or_default(), + location.country.clone().unwrap_or_default(), + ]); + tags.push(vec!["g".into(), location.geohash.clone()]); + } + + event_with_parts(SELLER, KIND_CLASSIFIED_LISTING, tags, String::new()) + } + + fn event_with_parts( + author: &str, + kind: u32, + tags: Vec<Vec<String>>, + content: String, + ) -> RadrootsEventEnvelope { + RadrootsEventEnvelope::new(radroots_event::RadrootsEventEnvelopeParts { + id: "9".repeat(64), + author: author.to_string(), + created_at: 0, + kind, + tags, + content, + sig: "f".repeat(128), + }) + .expect("event") + } + + fn assert_validation_err( + listing: RadrootsOperationalListing, + expected: OperationalListingValidationError, + ) { + let event = base_event(&listing); + let err = validate_operational_listing_event(&event).unwrap_err(); + assert_eq!(format!("{err}"), format!("{expected}")); + } + + #[test] + fn validate_listing_ok() { + let listing = base_listing(); + let event = base_event(&listing); + assert!(validate_operational_listing_event(&event).is_ok()); + } + + #[test] + fn validate_listing_rejects_retired_kind() { + let listing = base_listing(); + let event = event_with_parts( + SELLER, + 30403, + base_event(&listing).tags_as_vec(), + String::new(), + ); + let err = validate_operational_listing_event(&event).unwrap_err(); + assert_eq!( + err, + OperationalListingValidationError::InvalidKind { kind: 30403 } + ); + } + + #[test] + fn validate_listing_rejects_missing_d_tag() { + let event = event_with_parts(SELLER, KIND_CLASSIFIED_LISTING, Vec::new(), String::new()); + let err = validate_operational_listing_event(&event).unwrap_err(); + assert_eq!( + err, + OperationalListingValidationError::ParseError { + error: radroots_event::operational_listing::RadrootsOperationalListingParseError::MissingTag("d".to_string()) + } + ); + } + + #[test] + fn validate_listing_rejects_invalid_currency() { + let event = event_with_parts( + SELLER, + KIND_CLASSIFIED_LISTING, + vec![ + vec!["d".into(), "AAAAAAAAAAAAAAAAAAAAAg".into()], + vec!["p".into(), SELLER.into()], + vec!["a".into(), format!("30340:{SELLER}:AAAAAAAAAAAAAAAAAAAAAA")], + vec!["key".into(), "coffee".into()], + vec!["title".into(), "Coffee".into()], + vec!["category".into(), "coffee".into()], + vec!["summary".into(), "Single origin".into()], + vec![ + "quantity".into(), + "1".into(), + "lb".into(), + "bag".into(), + "5".into(), + ], + vec![ + "price".into(), + "20".into(), + "US".into(), + "1".into(), + "lb".into(), + ], + vec![ + "location".into(), + "Farm".into(), + "Town".into(), + "Region".into(), + ], + vec!["status".into(), "active".into()], + vec!["delivery".into(), "pickup".into()], + ], + String::new(), + ); + let err = validate_operational_listing_event(&event).unwrap_err(); + assert!(format!("{err:?}").starts_with("ParseError")); + } + + #[test] + fn validate_listing_rejects_mismatched_seller() { + let listing = base_listing(); + let event = event_with_parts( + OTHER_SELLER, + KIND_CLASSIFIED_LISTING, + base_event(&listing).tags_as_vec(), + String::new(), + ); + let err = validate_operational_listing_event(&event).unwrap_err(); + assert_eq!(err, OperationalListingValidationError::InvalidSeller); + } + + #[test] + fn validate_listing_rejects_missing_inventory() { + let mut listing = base_listing(); + listing.inventory_available = None; + let event = base_event(&listing); + let err = validate_operational_listing_event(&event).unwrap_err(); + assert_eq!(err, OperationalListingValidationError::MissingInventory); + } + + #[test] + fn validate_listing_rejects_invalid_kind() { + let listing = base_listing(); + let event = event_with_parts(SELLER, 0, base_event(&listing).tags_as_vec(), String::new()); + let err = validate_operational_listing_event(&event).unwrap_err(); + assert_eq!( + err, + OperationalListingValidationError::InvalidKind { kind: 0 } + ); + } + + #[test] + fn validate_listing_rejects_missing_title() { + let mut listing = base_listing(); + listing.product.title = " ".into(); + assert_validation_err(listing, OperationalListingValidationError::MissingTitle); + } + + #[test] + fn validate_listing_rejects_missing_description() { + let mut listing = base_listing(); + listing.product.summary = Some(" ".into()); + assert_validation_err( + listing, + OperationalListingValidationError::MissingDescription, + ); + } + + #[test] + fn validate_listing_rejects_missing_product_type() { + let mut listing = base_listing(); + listing.product.category = " ".into(); + listing.product.key = " ".into(); + assert_validation_err( + listing, + OperationalListingValidationError::MissingProductType, + ); + } + + #[test] + fn validate_listing_rejects_missing_bins() { + let mut listing = base_listing(); + listing.bins.clear(); + assert_validation_err( + listing, + OperationalListingValidationError::ParseError { + error: radroots_event::operational_listing::RadrootsOperationalListingParseError::InvalidTag("radroots:primary_bin".into()), + }, + ); + } + + #[test] + fn validate_listing_rejects_missing_primary_bin_id() { + assert!(RadrootsInventoryBinId::parse(" ").is_err()); + } + + #[test] + fn validate_listing_rejects_primary_bin_not_found() { + let mut listing = base_listing(); + listing.primary_bin_id = bin_id("missing"); + assert_validation_err( + listing, + OperationalListingValidationError::ParseError { + error: radroots_event::operational_listing::RadrootsOperationalListingParseError::InvalidTag("radroots:primary_bin".into()), + }, + ); + } + + #[test] + fn validate_listing_rejects_negative_quantity() { + let mut listing = base_listing(); + listing.bins[0].quantity.amount = "-1".parse().unwrap(); + assert_validation_err(listing, OperationalListingValidationError::InvalidBin); + } + + #[test] + fn validate_listing_rejects_non_canonical_quantity() { + let mut listing = base_listing(); + listing.bins[0].quantity.unit = RadrootsCoreUnit::MassKg; + assert_validation_err( + listing, + OperationalListingValidationError::ParseError { + error: radroots_event::operational_listing::RadrootsOperationalListingParseError::InvalidTag("radroots:bin".into()), + }, + ); + } + + #[test] + fn validate_listing_rejects_non_canonical_price_quantity() { + let mut listing = base_listing(); + listing.bins[0].price_per_canonical_unit.quantity.unit = RadrootsCoreUnit::MassKg; + assert_validation_err( + listing, + OperationalListingValidationError::ParseError { + error: radroots_event::operational_listing::RadrootsOperationalListingParseError::InvalidTag("radroots:price".into()), + }, + ); + } + + #[test] + fn validate_listing_rejects_negative_price_amount() { + let mut listing = base_listing(); + listing.bins[0].price_per_canonical_unit.amount.amount = "-1".parse().unwrap(); + assert_validation_err(listing, OperationalListingValidationError::InvalidPrice); + } + + #[test] + fn validate_listing_rejects_price_unit_mismatch() { + let mut listing = base_listing(); + listing.bins[0].price_per_canonical_unit.quantity.unit = RadrootsCoreUnit::Each; + assert_validation_err( + listing, + OperationalListingValidationError::ParseError { + error: radroots_event::operational_listing::RadrootsOperationalListingParseError::InvalidTag("radroots:price".into()), + }, + ); + } + + #[test] + fn validate_listing_rejects_negative_inventory() { + let mut listing = base_listing(); + listing.inventory_available = Some("-1".parse().unwrap()); + assert_validation_err(listing, OperationalListingValidationError::InvalidInventory); + } + + #[test] + fn validate_listing_rejects_missing_availability() { + let mut listing = base_listing(); + listing.availability = None; + assert_validation_err( + listing, + OperationalListingValidationError::MissingAvailability, + ); + } + + #[test] + fn validate_listing_rejects_missing_location() { + let mut listing = base_listing(); + listing.location = None; + assert_validation_err(listing, OperationalListingValidationError::MissingLocation); + } + + #[test] + fn validate_listing_rejects_missing_location_locality() { + let mut listing = base_listing(); + let location = listing.location.as_mut().expect("location"); + location.city = None; + location.region = None; + location.country = None; + assert_validation_err( + listing, + OperationalListingValidationError::MissingLocationLocality, + ); + } + + #[test] + fn validate_listing_rejects_missing_location_geohash() { + let mut listing = base_listing(); + listing.location.as_mut().expect("location").geohash = " ".into(); + assert_validation_err( + listing, + OperationalListingValidationError::ParseError { + error: radroots_event::operational_listing::RadrootsOperationalListingParseError::InvalidTag("g".to_string()), + }, + ); + } + + #[test] + fn validate_listing_rejects_invalid_location_geohash() { + let mut listing = base_listing(); + listing.location.as_mut().expect("location").geohash = "9q8yyz".into(); + assert_validation_err( + listing, + OperationalListingValidationError::ParseError { + error: radroots_event::operational_listing::RadrootsOperationalListingParseError::InvalidTag("g".to_string()), + }, + ); + } + + #[test] + fn validate_listing_rejects_missing_delivery_method() { + let mut listing = base_listing(); + listing.delivery_method = None; + assert_validation_err( + listing, + OperationalListingValidationError::MissingDeliveryMethod, + ); + } + + #[test] + fn validation_error_display_covers_all_variants() { + let errors = vec![ + OperationalListingValidationError::InvalidKind { kind: 9 }, + OperationalListingValidationError::MissingListingId, + OperationalListingValidationError::ListingEventNotFound { + listing_addr: "addr".into(), + }, + OperationalListingValidationError::ListingEventFetchFailed { + listing_addr: "addr".into(), + }, + OperationalListingValidationError::ParseError { + error: radroots_event::operational_listing::RadrootsOperationalListingParseError::InvalidTag("d".into()), + }, + OperationalListingValidationError::InvalidSeller, + OperationalListingValidationError::MissingFarmProfile, + OperationalListingValidationError::MissingFarmRecord, + OperationalListingValidationError::MissingTitle, + OperationalListingValidationError::MissingDescription, + OperationalListingValidationError::MissingProductType, + OperationalListingValidationError::MissingBins, + OperationalListingValidationError::MissingPrimaryBin, + OperationalListingValidationError::InvalidBin, + OperationalListingValidationError::MissingPrice, + OperationalListingValidationError::InvalidPrice, + OperationalListingValidationError::MissingInventory, + OperationalListingValidationError::InvalidInventory, + OperationalListingValidationError::MissingAvailability, + OperationalListingValidationError::MissingLocation, + OperationalListingValidationError::MissingLocationLocality, + OperationalListingValidationError::MissingLocationGeohash, + OperationalListingValidationError::InvalidLocationGeohash, + OperationalListingValidationError::MissingDeliveryMethod, + ]; + for error in errors { + assert!(!error.to_string().trim().is_empty()); + } + } +} diff --git a/crates/trade/src/order.rs b/crates/trade/src/order.rs @@ -11,8 +11,8 @@ use radroots_event::RadrootsEventEnvelope; #[cfg(feature = "event_store")] use radroots_event::RadrootsEventEnvelopeParts; use radroots_event::ids::{ - RadrootsEventId, RadrootsIdParseError, RadrootsInventoryBinId, RadrootsListingAddress, - RadrootsOrderId, RadrootsPublicKey, + RadrootsClassifiedListingAddress, RadrootsEventId, RadrootsIdParseError, + RadrootsInventoryBinId, RadrootsOrderId, RadrootsPublicKey, }; #[cfg(feature = "serde_json")] use radroots_event::order::RadrootsOrderEventType; @@ -35,8 +35,8 @@ use sha2::{Digest, Sha256}; use thiserror::Error; use crate::identity::{RadrootsTradeLocator, RadrootsTradeLocatorCandidate}; -use crate::listing::{ - RadrootsPublicListingAddress, RadrootsPublicListingAddressError, parse_public_listing_address, +use crate::operational_listing::{ + RadrootsPublicClassifiedListingAddress, parse_public_classified_listing_address, }; use crate::workflow::{RadrootsTradeWorkflowState, inventory_reservations_from_commitments}; @@ -44,10 +44,6 @@ use crate::workflow::{RadrootsTradeWorkflowState, inventory_reservations_from_co pub enum RadrootsOrderCanonicalizationError { #[error("{0} cannot be empty")] EmptyField(&'static str), - #[error("invalid listing_addr: {0}")] - InvalidListingAddress(String), - #[error("listing_addr must reference a public NIP-99 listing")] - InvalidListingKind, #[error("buyer_pubkey must match the requested signer identity")] InvalidBuyerSigner, #[error("seller_pubkey must match listing_addr seller")] @@ -372,9 +368,6 @@ pub enum RadrootsOrderIssue { RequestAuthorMismatch { event_id: RadrootsEventId, }, - RequestListingAddressInvalid { - event_id: RadrootsEventId, - }, RequestSellerListingMismatch { event_id: RadrootsEventId, }, @@ -396,9 +389,6 @@ pub enum RadrootsOrderIssue { DecisionSellerMismatch { event_id: RadrootsEventId, }, - DecisionListingAddressInvalid { - event_id: RadrootsEventId, - }, DecisionListingMismatch { event_id: RadrootsEventId, }, @@ -441,9 +431,6 @@ pub enum RadrootsOrderIssue { CancellationSellerMismatch { event_id: RadrootsEventId, }, - CancellationListingAddressInvalid { - event_id: RadrootsEventId, - }, CancellationListingMismatch { event_id: RadrootsEventId, }, @@ -500,7 +487,7 @@ pub struct RadrootsOrderProjection { pub agreement_event_id: Option<RadrootsEventId>, pub pending_inventory_reservations: Vec<RadrootsOrderInventoryCommitment>, pub committed_inventory_reservations: Vec<RadrootsOrderInventoryCommitment>, - pub listing_addr: Option<RadrootsListingAddress>, + pub listing_addr: Option<RadrootsClassifiedListingAddress>, pub buyer_pubkey: Option<RadrootsPublicKey>, pub seller_pubkey: Option<RadrootsPublicKey>, pub last_event_id: Option<RadrootsEventId>, @@ -532,7 +519,7 @@ pub struct RadrootsOrderWorkflowProjection { pub agreement_event_id: Option<RadrootsEventId>, pub pending_inventory_reservations: Vec<RadrootsOrderInventoryCommitment>, pub committed_inventory_reservations: Vec<RadrootsOrderInventoryCommitment>, - pub listing_addr: Option<RadrootsListingAddress>, + pub listing_addr: Option<RadrootsClassifiedListingAddress>, pub buyer_pubkey: Option<RadrootsPublicKey>, pub seller_pubkey: Option<RadrootsPublicKey>, pub last_event_id: Option<RadrootsEventId>, @@ -576,32 +563,32 @@ pub enum RadrootsOrderEconomicsDigestError { } #[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsListingInventoryBinAvailability { +pub struct RadrootsOperationalListingInventoryBinAvailability { pub bin_id: RadrootsInventoryBinId, pub available_count: u64, } #[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsListingInventoryOrderReservation { +pub struct RadrootsOperationalListingInventoryOrderReservation { pub order_id: RadrootsOrderId, pub agreement_event_id: RadrootsEventId, pub bin_count: u64, } #[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsListingInventoryBinAccounting { +pub struct RadrootsOperationalListingInventoryBinAccounting { pub bin_id: RadrootsInventoryBinId, pub available_count: u64, pub pending_reserved_count: u64, pub committed_reserved_count: u64, pub remaining_count: u64, pub over_reserved: bool, - pub pending_orders: Vec<RadrootsListingInventoryOrderReservation>, - pub committed_orders: Vec<RadrootsListingInventoryOrderReservation>, + pub pending_orders: Vec<RadrootsOperationalListingInventoryOrderReservation>, + pub committed_orders: Vec<RadrootsOperationalListingInventoryOrderReservation>, } #[derive(Clone, Debug, PartialEq, Eq)] -pub enum RadrootsListingInventoryAccountingIssue { +pub enum RadrootsOperationalListingInventoryAccountingIssue { InvalidOrder { order_id: RadrootsOrderId, event_ids: Vec<RadrootsEventId>, @@ -623,14 +610,14 @@ pub enum RadrootsListingInventoryAccountingIssue { } #[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsListingInventoryAccountingProjection { - pub listing_addr: RadrootsListingAddress, +pub struct RadrootsOperationalListingInventoryAccountingProjection { + pub listing_addr: RadrootsClassifiedListingAddress, pub listing_event_id: RadrootsEventId, - pub bins: Vec<RadrootsListingInventoryBinAccounting>, + pub bins: Vec<RadrootsOperationalListingInventoryBinAccounting>, pub declined_order_ids: Vec<RadrootsOrderId>, pub cancelled_order_ids: Vec<RadrootsOrderId>, pub invalid_event_ids: Vec<RadrootsEventId>, - pub issues: Vec<RadrootsListingInventoryAccountingIssue>, + pub issues: Vec<RadrootsOperationalListingInventoryAccountingIssue>, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -663,7 +650,7 @@ pub(crate) enum RadrootsTradeLocatorGroupedOrderEventRecordsResolution { } #[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsListingInventoryAccountingInputs<I, J, K, L> { +pub struct RadrootsOperationalListingInventoryAccountingInputs<I, J, K, L> { pub bins: I, pub requests: J, pub decisions: K, @@ -671,8 +658,8 @@ pub struct RadrootsListingInventoryAccountingInputs<I, J, K, L> { } #[derive(Clone, Debug, Default, PartialEq, Eq)] -struct RadrootsListingInventoryAccountingRecords { - bins: Vec<RadrootsListingInventoryBinAvailability>, +struct RadrootsOperationalListingInventoryAccountingRecords { + bins: Vec<RadrootsOperationalListingInventoryBinAvailability>, requests: Vec<RadrootsOrderRequestRecord>, decisions: Vec<RadrootsOrderDecisionRecord>, cancellations: Vec<RadrootsOrderCancellationRecord>, @@ -957,21 +944,21 @@ pub(crate) fn reduce_grouped_order_event_records( } } -pub fn reduce_listing_inventory_accounting<I, J, K, L>( - listing_addr: &RadrootsListingAddress, +pub fn reduce_operational_listing_inventory_accounting<I, J, K, L>( + listing_addr: &RadrootsClassifiedListingAddress, listing_event_id: &RadrootsEventId, - inputs: RadrootsListingInventoryAccountingInputs<I, J, K, L>, -) -> RadrootsListingInventoryAccountingProjection + inputs: RadrootsOperationalListingInventoryAccountingInputs<I, J, K, L>, +) -> RadrootsOperationalListingInventoryAccountingProjection where - I: IntoIterator<Item = RadrootsListingInventoryBinAvailability>, + I: IntoIterator<Item = RadrootsOperationalListingInventoryBinAvailability>, J: IntoIterator<Item = RadrootsOrderRequestRecord>, K: IntoIterator<Item = RadrootsOrderDecisionRecord>, L: IntoIterator<Item = RadrootsOrderCancellationRecord>, { - reduce_listing_inventory_accounting_records( + reduce_operational_listing_inventory_accounting_records( listing_addr, listing_event_id, - RadrootsListingInventoryAccountingRecords { + RadrootsOperationalListingInventoryAccountingRecords { bins: inputs.bins.into_iter().collect(), requests: inputs.requests.into_iter().collect(), decisions: inputs.decisions.into_iter().collect(), @@ -980,11 +967,11 @@ where ) } -fn reduce_listing_inventory_accounting_records( - listing_addr: &RadrootsListingAddress, +fn reduce_operational_listing_inventory_accounting_records( + listing_addr: &RadrootsClassifiedListingAddress, listing_event_id: &RadrootsEventId, - records: RadrootsListingInventoryAccountingRecords, -) -> RadrootsListingInventoryAccountingProjection { + records: RadrootsOperationalListingInventoryAccountingRecords, +) -> RadrootsOperationalListingInventoryAccountingProjection { let (mut bins, mut issues) = normalized_listing_inventory_bins(records.bins); let requests = unique_request_records(records.requests) .into_iter() @@ -1055,10 +1042,12 @@ fn reduce_listing_inventory_accounting_records( ); } invalid_event_ids.extend(event_ids.iter().cloned()); - issues.push(RadrootsListingInventoryAccountingIssue::InvalidOrder { - order_id, - event_ids, - }); + issues.push( + RadrootsOperationalListingInventoryAccountingIssue::InvalidOrder { + order_id, + event_ids, + }, + ); } RadrootsTradeWorkflowState::Missing | RadrootsTradeWorkflowState::Requested @@ -1072,7 +1061,7 @@ fn reduce_listing_inventory_accounting_records( sort_and_dedup_values(&mut invalid_event_ids); finish_inventory_accounting_bins(&mut bins, &mut issues); issues.sort_by(inventory_issue_sort_key); - RadrootsListingInventoryAccountingProjection { + RadrootsOperationalListingInventoryAccountingProjection { listing_addr: listing_addr.clone(), listing_event_id: listing_event_id.clone(), bins, @@ -1105,8 +1094,7 @@ pub fn canonicalize_order_request_for_signer( signer_pubkey: &str, ) -> Result<RadrootsOrderRequest, RadrootsOrderCanonicalizationError> { let order_id = request.order_id.clone(); - let listing_addr_raw = request.listing_addr.to_string(); - let listing_addr = parse_public_listing_addr(&listing_addr_raw)?; + let listing_addr = public_listing_addr(&request.listing_addr); let buyer_pubkey = request.buyer_pubkey.clone(); if buyer_pubkey.as_str() != signer_pubkey { @@ -1132,8 +1120,7 @@ pub fn canonicalize_order_decision_for_signer( signer_pubkey: &str, ) -> Result<RadrootsOrderDecision, RadrootsOrderCanonicalizationError> { let order_id = decision_event.order_id.clone(); - let listing_addr_raw = decision_event.listing_addr.to_string(); - let listing_addr = parse_public_listing_addr(&listing_addr_raw)?; + let listing_addr = public_listing_addr(&decision_event.listing_addr); let seller_pubkey = decision_event.seller_pubkey.clone(); if seller_pubkey.as_str() != signer_pubkey || seller_pubkey != listing_addr.seller_pubkey { @@ -1335,21 +1322,12 @@ fn validate_order_request_record( }); valid = false; } - match parse_public_listing_addr(&request.payload.listing_addr) { - Ok(listing_addr) => { - if listing_addr.seller_pubkey != request.payload.seller_pubkey { - issues.push(RadrootsOrderIssue::RequestSellerListingMismatch { - event_id: request.event_id.clone(), - }); - valid = false; - } - } - Err(_) => { - issues.push(RadrootsOrderIssue::RequestListingAddressInvalid { - event_id: request.event_id.clone(), - }); - valid = false; - } + let listing_addr = public_listing_addr(&request.payload.listing_addr); + if listing_addr.seller_pubkey != request.payload.seller_pubkey { + issues.push(RadrootsOrderIssue::RequestSellerListingMismatch { + event_id: request.event_id.clone(), + }); + valid = false; } valid } @@ -1399,23 +1377,14 @@ fn validate_order_decision_record( }); valid = false; } - match parse_public_listing_addr(&decision.payload.listing_addr) { - Ok(listing_addr) => { - if decision.payload.listing_addr != request.payload.listing_addr - || listing_addr.seller_pubkey != decision.payload.seller_pubkey - { - issues.push(RadrootsOrderIssue::DecisionListingMismatch { - event_id: decision.event_id.clone(), - }); - valid = false; - } - } - Err(_) => { - issues.push(RadrootsOrderIssue::DecisionListingAddressInvalid { - event_id: decision.event_id.clone(), - }); - valid = false; - } + let listing_addr = public_listing_addr(&decision.payload.listing_addr); + if decision.payload.listing_addr != request.payload.listing_addr + || listing_addr.seller_pubkey != decision.payload.seller_pubkey + { + issues.push(RadrootsOrderIssue::DecisionListingMismatch { + event_id: decision.event_id.clone(), + }); + valid = false; } if decision.root_event_id != request.event_id { issues.push(RadrootsOrderIssue::DecisionRootMismatch { @@ -1485,23 +1454,14 @@ fn validate_order_cancellation_record( }); valid = false; } - match parse_public_listing_addr(&cancellation.payload.listing_addr) { - Ok(listing_addr) => { - if cancellation.payload.listing_addr != request.payload.listing_addr - || listing_addr.seller_pubkey != cancellation.payload.seller_pubkey - { - issues.push(RadrootsOrderIssue::CancellationListingMismatch { - event_id: cancellation.event_id.clone(), - }); - valid = false; - } - } - Err(_) => { - issues.push(RadrootsOrderIssue::CancellationListingAddressInvalid { - event_id: cancellation.event_id.clone(), - }); - valid = false; - } + let listing_addr = public_listing_addr(&cancellation.payload.listing_addr); + if cancellation.payload.listing_addr != request.payload.listing_addr + || listing_addr.seller_pubkey != cancellation.payload.seller_pubkey + { + issues.push(RadrootsOrderIssue::CancellationListingMismatch { + event_id: cancellation.event_id.clone(), + }); + valid = false; } if cancellation.root_event_id != request.event_id { issues.push(RadrootsOrderIssue::CancellationRootMismatch { @@ -1587,13 +1547,13 @@ fn unique_records_by_event_id<T>( fn normalized_listing_inventory_bins<I>( bins: I, ) -> ( - Vec<RadrootsListingInventoryBinAccounting>, - Vec<RadrootsListingInventoryAccountingIssue>, + Vec<RadrootsOperationalListingInventoryBinAccounting>, + Vec<RadrootsOperationalListingInventoryAccountingIssue>, ) where - I: IntoIterator<Item = RadrootsListingInventoryBinAvailability>, + I: IntoIterator<Item = RadrootsOperationalListingInventoryBinAvailability>, { - let mut normalized: Vec<RadrootsListingInventoryBinAccounting> = Vec::new(); + let mut normalized: Vec<RadrootsOperationalListingInventoryBinAccounting> = Vec::new(); let mut issues = Vec::new(); for bin in bins { let bin_id = bin.bin_id; @@ -1608,14 +1568,14 @@ where existing.available_count = u64::MAX; existing.remaining_count = u64::MAX; issues.push( - RadrootsListingInventoryAccountingIssue::ArithmeticOverflow { + RadrootsOperationalListingInventoryAccountingIssue::ArithmeticOverflow { bin_id: existing.bin_id.clone(), event_ids: Vec::new(), }, ); } } else { - normalized.push(RadrootsListingInventoryBinAccounting { + normalized.push(RadrootsOperationalListingInventoryBinAccounting { bin_id, available_count: bin.available_count, pending_reserved_count: 0, @@ -1657,11 +1617,11 @@ fn listing_order_ids( } fn add_pending_inventory_reservations_from_economics( - bins: &mut [RadrootsListingInventoryBinAccounting], + bins: &mut [RadrootsOperationalListingInventoryBinAccounting], order_id: &RadrootsOrderId, agreement_event_id: &RadrootsEventId, economics: &RadrootsOrderEconomics, - issues: &mut Vec<RadrootsListingInventoryAccountingIssue>, + issues: &mut Vec<RadrootsOperationalListingInventoryAccountingIssue>, ) { for item in &economics.items { if let Some(bin) = bins.iter_mut().find(|bin| bin.bin_id == item.bin_id) { @@ -1674,7 +1634,7 @@ fn add_pending_inventory_reservations_from_economics( ); } else { issues.push( - RadrootsListingInventoryAccountingIssue::UnknownInventoryBin { + RadrootsOperationalListingInventoryAccountingIssue::UnknownInventoryBin { bin_id: item.bin_id.clone(), event_ids: vec![agreement_event_id.clone()], }, @@ -1684,23 +1644,23 @@ fn add_pending_inventory_reservations_from_economics( } fn add_inventory_reservation_event( - bin: &mut RadrootsListingInventoryBinAccounting, + bin: &mut RadrootsOperationalListingInventoryBinAccounting, order_id: &RadrootsOrderId, event_id: &RadrootsEventId, bin_count: u64, - issues: &mut Vec<RadrootsListingInventoryAccountingIssue>, + issues: &mut Vec<RadrootsOperationalListingInventoryAccountingIssue>, ) { if let Some(next_count) = bin.pending_reserved_count.checked_add(bin_count) { bin.pending_reserved_count = next_count; bin.pending_orders - .push(RadrootsListingInventoryOrderReservation { + .push(RadrootsOperationalListingInventoryOrderReservation { order_id: order_id.clone(), agreement_event_id: event_id.clone(), bin_count, }); } else { issues.push( - RadrootsListingInventoryAccountingIssue::ArithmeticOverflow { + RadrootsOperationalListingInventoryAccountingIssue::ArithmeticOverflow { bin_id: bin.bin_id.clone(), event_ids: vec![event_id.clone()], }, @@ -1709,8 +1669,8 @@ fn add_inventory_reservation_event( } fn finish_inventory_accounting_bins( - bins: &mut [RadrootsListingInventoryBinAccounting], - issues: &mut Vec<RadrootsListingInventoryAccountingIssue>, + bins: &mut [RadrootsOperationalListingInventoryBinAccounting], + issues: &mut Vec<RadrootsOperationalListingInventoryAccountingIssue>, ) { for bin in bins.iter_mut() { bin.pending_orders.sort_by(|left, right| { @@ -1736,12 +1696,14 @@ fn finish_inventory_accounting_bins( .map(|reservation| reservation.agreement_event_id.clone()) .collect::<Vec<_>>(); sort_and_dedup_values(&mut event_ids); - issues.push(RadrootsListingInventoryAccountingIssue::OverReserved { - bin_id: bin.bin_id.clone(), - available_count: bin.available_count, - reserved_count, - event_ids, - }); + issues.push( + RadrootsOperationalListingInventoryAccountingIssue::OverReserved { + bin_id: bin.bin_id.clone(), + available_count: bin.available_count, + reserved_count, + event_ids, + }, + ); } } bins.sort_by(|left, right| left.bin_id.cmp(&right.bin_id)); @@ -1761,7 +1723,6 @@ fn projection_issue_event_ids(issues: &[RadrootsOrderIssue]) -> Vec<RadrootsEven RadrootsOrderIssue::RequestPayloadInvalid { event_id } | RadrootsOrderIssue::RequestOrderIdMismatch { event_id } | RadrootsOrderIssue::RequestAuthorMismatch { event_id } - | RadrootsOrderIssue::RequestListingAddressInvalid { event_id } | RadrootsOrderIssue::RequestSellerListingMismatch { event_id } | RadrootsOrderIssue::DecisionPayloadInvalid { event_id } | RadrootsOrderIssue::DecisionOrderIdMismatch { event_id } @@ -1769,7 +1730,6 @@ fn projection_issue_event_ids(issues: &[RadrootsOrderIssue]) -> Vec<RadrootsEven | RadrootsOrderIssue::DecisionCounterpartyMismatch { event_id } | RadrootsOrderIssue::DecisionBuyerMismatch { event_id } | RadrootsOrderIssue::DecisionSellerMismatch { event_id } - | RadrootsOrderIssue::DecisionListingAddressInvalid { event_id } | RadrootsOrderIssue::DecisionListingMismatch { event_id } | RadrootsOrderIssue::DecisionRootMismatch { event_id } | RadrootsOrderIssue::DecisionPreviousMismatch { event_id } @@ -1783,7 +1743,6 @@ fn projection_issue_event_ids(issues: &[RadrootsOrderIssue]) -> Vec<RadrootsEven | RadrootsOrderIssue::CancellationCounterpartyMismatch { event_id } | RadrootsOrderIssue::CancellationBuyerMismatch { event_id } | RadrootsOrderIssue::CancellationSellerMismatch { event_id } - | RadrootsOrderIssue::CancellationListingAddressInvalid { event_id } | RadrootsOrderIssue::CancellationListingMismatch { event_id } | RadrootsOrderIssue::CancellationRootMismatch { event_id } | RadrootsOrderIssue::CancellationPreviousMismatch { event_id } @@ -1809,20 +1768,11 @@ fn projection_issue_event_ids(issues: &[RadrootsOrderIssue]) -> Vec<RadrootsEven event_ids } -fn parse_public_listing_addr( - value: impl AsRef<str>, -) -> Result<RadrootsPublicListingAddress, RadrootsOrderCanonicalizationError> { - parse_public_listing_address(value).map_err(|error| match error { - RadrootsPublicListingAddressError::InvalidAddress(error) => { - RadrootsOrderCanonicalizationError::InvalidListingAddress(error.to_string()) - } - RadrootsPublicListingAddressError::InvalidListingKind { .. } => { - RadrootsOrderCanonicalizationError::InvalidListingKind - } - RadrootsPublicListingAddressError::InvalidKind { .. } => { - RadrootsOrderCanonicalizationError::InvalidListingKind - } - }) +fn public_listing_addr( + value: &RadrootsClassifiedListingAddress, +) -> RadrootsPublicClassifiedListingAddress { + parse_public_classified_listing_address(value) + .expect("typed classified listing address must remain a valid kind-30402 coordinate") } fn canonicalize_items( @@ -1910,8 +1860,8 @@ fn sort_and_dedup_values<T: Ord>(values: &mut Vec<T>) { } fn inventory_issue_sort_key( - left: &RadrootsListingInventoryAccountingIssue, - right: &RadrootsListingInventoryAccountingIssue, + left: &RadrootsOperationalListingInventoryAccountingIssue, + right: &RadrootsOperationalListingInventoryAccountingIssue, ) -> core::cmp::Ordering { inventory_issue_rank(left) .cmp(&inventory_issue_rank(right)) @@ -1919,32 +1869,46 @@ fn inventory_issue_sort_key( .then_with(|| inventory_issue_event_ids(left).cmp(inventory_issue_event_ids(right))) } -fn inventory_issue_rank(issue: &RadrootsListingInventoryAccountingIssue) -> u8 { +fn inventory_issue_rank(issue: &RadrootsOperationalListingInventoryAccountingIssue) -> u8 { match issue { - RadrootsListingInventoryAccountingIssue::InvalidOrder { .. } => 0, - RadrootsListingInventoryAccountingIssue::ArithmeticOverflow { .. } => 1, - RadrootsListingInventoryAccountingIssue::UnknownInventoryBin { .. } => 2, - RadrootsListingInventoryAccountingIssue::OverReserved { .. } => 3, + RadrootsOperationalListingInventoryAccountingIssue::InvalidOrder { .. } => 0, + RadrootsOperationalListingInventoryAccountingIssue::ArithmeticOverflow { .. } => 1, + RadrootsOperationalListingInventoryAccountingIssue::UnknownInventoryBin { .. } => 2, + RadrootsOperationalListingInventoryAccountingIssue::OverReserved { .. } => 3, } } -fn inventory_issue_id(issue: &RadrootsListingInventoryAccountingIssue) -> &str { +fn inventory_issue_id(issue: &RadrootsOperationalListingInventoryAccountingIssue) -> &str { match issue { - RadrootsListingInventoryAccountingIssue::InvalidOrder { order_id, .. } => order_id, - RadrootsListingInventoryAccountingIssue::ArithmeticOverflow { bin_id, .. } - | RadrootsListingInventoryAccountingIssue::UnknownInventoryBin { bin_id, .. } - | RadrootsListingInventoryAccountingIssue::OverReserved { bin_id, .. } => bin_id, + RadrootsOperationalListingInventoryAccountingIssue::InvalidOrder { order_id, .. } => { + order_id + } + RadrootsOperationalListingInventoryAccountingIssue::ArithmeticOverflow { + bin_id, .. + } + | RadrootsOperationalListingInventoryAccountingIssue::UnknownInventoryBin { + bin_id, .. + } + | RadrootsOperationalListingInventoryAccountingIssue::OverReserved { bin_id, .. } => bin_id, } } fn inventory_issue_event_ids( - issue: &RadrootsListingInventoryAccountingIssue, + issue: &RadrootsOperationalListingInventoryAccountingIssue, ) -> &[RadrootsEventId] { match issue { - RadrootsListingInventoryAccountingIssue::InvalidOrder { event_ids, .. } - | RadrootsListingInventoryAccountingIssue::ArithmeticOverflow { event_ids, .. } - | RadrootsListingInventoryAccountingIssue::UnknownInventoryBin { event_ids, .. } - | RadrootsListingInventoryAccountingIssue::OverReserved { event_ids, .. } => event_ids, + RadrootsOperationalListingInventoryAccountingIssue::InvalidOrder { event_ids, .. } + | RadrootsOperationalListingInventoryAccountingIssue::ArithmeticOverflow { + event_ids, + .. + } + | RadrootsOperationalListingInventoryAccountingIssue::UnknownInventoryBin { + event_ids, + .. + } + | RadrootsOperationalListingInventoryAccountingIssue::OverReserved { event_ids, .. } => { + event_ids + } } } @@ -1967,43 +1931,40 @@ fn order_issue_rank(issue: &RadrootsOrderIssue) -> u8 { RadrootsOrderIssue::RequestPayloadInvalid { .. } => 2, RadrootsOrderIssue::RequestOrderIdMismatch { .. } => 3, RadrootsOrderIssue::RequestAuthorMismatch { .. } => 4, - RadrootsOrderIssue::RequestListingAddressInvalid { .. } => 5, - RadrootsOrderIssue::RequestSellerListingMismatch { .. } => 6, - RadrootsOrderIssue::DecisionPayloadInvalid { .. } => 7, - RadrootsOrderIssue::DecisionOrderIdMismatch { .. } => 8, - RadrootsOrderIssue::DecisionAuthorMismatch { .. } => 9, - RadrootsOrderIssue::DecisionCounterpartyMismatch { .. } => 10, - RadrootsOrderIssue::DecisionBuyerMismatch { .. } => 11, - RadrootsOrderIssue::DecisionSellerMismatch { .. } => 12, - RadrootsOrderIssue::DecisionListingAddressInvalid { .. } => 13, - RadrootsOrderIssue::DecisionListingMismatch { .. } => 14, - RadrootsOrderIssue::DecisionRootMismatch { .. } => 15, - RadrootsOrderIssue::DecisionPreviousMismatch { .. } => 16, - RadrootsOrderIssue::DecisionMissingInventoryCommitments { .. } => 17, - RadrootsOrderIssue::DecisionInventoryCommitmentMismatch { .. } => 18, - RadrootsOrderIssue::DecisionMissingReason { .. } => 19, - RadrootsOrderIssue::ConflictingDecisions { .. } => 20, - RadrootsOrderIssue::CancellationWithoutCancellableOrder { .. } => 21, - RadrootsOrderIssue::CancellationPayloadInvalid { .. } => 22, - RadrootsOrderIssue::CancellationOrderIdMismatch { .. } => 23, - RadrootsOrderIssue::CancellationAuthorMismatch { .. } => 24, - RadrootsOrderIssue::CancellationCounterpartyMismatch { .. } => 25, - RadrootsOrderIssue::CancellationBuyerMismatch { .. } => 26, - RadrootsOrderIssue::CancellationSellerMismatch { .. } => 27, - RadrootsOrderIssue::CancellationListingAddressInvalid { .. } => 28, - RadrootsOrderIssue::CancellationListingMismatch { .. } => 29, - RadrootsOrderIssue::CancellationRootMismatch { .. } => 30, - RadrootsOrderIssue::CancellationPreviousMismatch { .. } => 31, - RadrootsOrderIssue::ForkedLifecycle { .. } => 32, - RadrootsOrderIssue::ValidationReceiptWithoutPendingAgreement { .. } => 33, - RadrootsOrderIssue::ValidationReceiptOrderIdMismatch { .. } => 34, - RadrootsOrderIssue::ValidationReceiptTypeMismatch { .. } => 35, - RadrootsOrderIssue::ValidationReceiptRootMismatch { .. } => 36, - RadrootsOrderIssue::ValidationReceiptTargetMismatch { .. } => 37, - RadrootsOrderIssue::ValidationReceiptListingMismatch { .. } => 38, - RadrootsOrderIssue::ConflictingValidationReceipts { .. } => 39, - RadrootsOrderIssue::DeterministicValidationFailure { .. } => 40, - RadrootsOrderIssue::StaleListingEvent { .. } => 41, + RadrootsOrderIssue::RequestSellerListingMismatch { .. } => 5, + RadrootsOrderIssue::DecisionPayloadInvalid { .. } => 6, + RadrootsOrderIssue::DecisionOrderIdMismatch { .. } => 7, + RadrootsOrderIssue::DecisionAuthorMismatch { .. } => 8, + RadrootsOrderIssue::DecisionCounterpartyMismatch { .. } => 9, + RadrootsOrderIssue::DecisionBuyerMismatch { .. } => 10, + RadrootsOrderIssue::DecisionSellerMismatch { .. } => 11, + RadrootsOrderIssue::DecisionListingMismatch { .. } => 12, + RadrootsOrderIssue::DecisionRootMismatch { .. } => 13, + RadrootsOrderIssue::DecisionPreviousMismatch { .. } => 14, + RadrootsOrderIssue::DecisionMissingInventoryCommitments { .. } => 15, + RadrootsOrderIssue::DecisionInventoryCommitmentMismatch { .. } => 16, + RadrootsOrderIssue::DecisionMissingReason { .. } => 17, + RadrootsOrderIssue::ConflictingDecisions { .. } => 18, + RadrootsOrderIssue::CancellationWithoutCancellableOrder { .. } => 19, + RadrootsOrderIssue::CancellationPayloadInvalid { .. } => 20, + RadrootsOrderIssue::CancellationOrderIdMismatch { .. } => 21, + RadrootsOrderIssue::CancellationAuthorMismatch { .. } => 22, + RadrootsOrderIssue::CancellationCounterpartyMismatch { .. } => 23, + RadrootsOrderIssue::CancellationBuyerMismatch { .. } => 24, + RadrootsOrderIssue::CancellationSellerMismatch { .. } => 25, + RadrootsOrderIssue::CancellationListingMismatch { .. } => 26, + RadrootsOrderIssue::CancellationRootMismatch { .. } => 27, + RadrootsOrderIssue::CancellationPreviousMismatch { .. } => 28, + RadrootsOrderIssue::ForkedLifecycle { .. } => 29, + RadrootsOrderIssue::ValidationReceiptWithoutPendingAgreement { .. } => 30, + RadrootsOrderIssue::ValidationReceiptOrderIdMismatch { .. } => 31, + RadrootsOrderIssue::ValidationReceiptTypeMismatch { .. } => 32, + RadrootsOrderIssue::ValidationReceiptRootMismatch { .. } => 33, + RadrootsOrderIssue::ValidationReceiptTargetMismatch { .. } => 34, + RadrootsOrderIssue::ValidationReceiptListingMismatch { .. } => 35, + RadrootsOrderIssue::ConflictingValidationReceipts { .. } => 36, + RadrootsOrderIssue::DeterministicValidationFailure { .. } => 37, + RadrootsOrderIssue::StaleListingEvent { .. } => 38, } } @@ -2011,12 +1972,13 @@ fn order_issue_rank(issue: &RadrootsOrderIssue) -> u8 { #[cfg_attr(coverage_nightly, coverage(off))] mod tests { use super::{ - RadrootsListingInventoryAccountingInputs, RadrootsListingInventoryAccountingIssue, - RadrootsListingInventoryBinAvailability, RadrootsOrderCancellationRecord, + RadrootsOperationalListingInventoryAccountingInputs, + RadrootsOperationalListingInventoryAccountingIssue, + RadrootsOperationalListingInventoryBinAvailability, RadrootsOrderCancellationRecord, RadrootsOrderDecisionRecord, RadrootsOrderEventRecord, RadrootsOrderIssue, RadrootsOrderReductionInputs, RadrootsOrderRequestRecord, RadrootsOrderWorkflowProjection, RadrootsTradeLocatorProjectionResolution, RadrootsTradeWorkflowState, - reduce_listing_inventory_accounting, reduce_order_event_records, + reduce_operational_listing_inventory_accounting, reduce_order_event_records, reduce_order_event_records_for_trade_locator, reduce_order_events, }; use crate::identity::{RadrootsTradeLocator, RadrootsTradeLocatorCandidate}; @@ -2027,10 +1989,10 @@ mod tests { use radroots_event::{ RadrootsEventEnvelope, RadrootsEventEnvelopeParts, RadrootsEventPtr, ids::{ - RadrootsEventId, RadrootsInventoryBinId, RadrootsListingAddress, RadrootsOrderId, - RadrootsOrderQuoteId, RadrootsPublicKey, + RadrootsClassifiedListingAddress, RadrootsEventId, RadrootsInventoryBinId, + RadrootsOrderId, RadrootsOrderQuoteId, RadrootsPublicKey, }, - kinds::KIND_LISTING, + kinds::KIND_CLASSIFIED_LISTING, order::{ RadrootsOrderCancellation, RadrootsOrderDecision, RadrootsOrderDecisionOutcome, RadrootsOrderEconomicItem, RadrootsOrderEconomics, RadrootsOrderInventoryCommitment, @@ -2067,19 +2029,18 @@ mod tests { RadrootsInventoryBinId::parse(raw).expect("bin id") } - fn listing_addr() -> RadrootsListingAddress { - RadrootsListingAddress::parse(format!("{KIND_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg")) - .expect("listing address") - } - - fn other_seller_listing_addr() -> RadrootsListingAddress { - RadrootsListingAddress::parse(format!("{KIND_LISTING}:{OTHER}:AAAAAAAAAAAAAAAAAAAAAg")) - .expect("other seller listing address") + fn listing_addr() -> RadrootsClassifiedListingAddress { + RadrootsClassifiedListingAddress::parse(format!( + "{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg" + )) + .expect("listing address") } - fn retired_listing_addr() -> RadrootsListingAddress { - RadrootsListingAddress::parse(format!("30403:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg")) - .expect("retired listing address") + fn other_seller_listing_addr() -> RadrootsClassifiedListingAddress { + RadrootsClassifiedListingAddress::parse(format!( + "{KIND_CLASSIFIED_LISTING}:{OTHER}:AAAAAAAAAAAAAAAAAAAAAg" + )) + .expect("other seller listing address") } #[cfg(feature = "serde_json")] @@ -2230,8 +2191,8 @@ mod tests { } fn assert_inventory_issue_kind( - issues: &[RadrootsListingInventoryAccountingIssue], - expected: RadrootsListingInventoryAccountingIssue, + issues: &[RadrootsOperationalListingInventoryAccountingIssue], + expected: RadrootsOperationalListingInventoryAccountingIssue, ) { let expected_kind = discriminant(&expected); assert!( @@ -2690,19 +2651,6 @@ mod tests { assert_eq!(canonical.items[1].bin_id, bin_id("bin-1")); assert!(matches!( - super::parse_public_listing_addr("not-an-address"), - Err(super::RadrootsOrderCanonicalizationError::InvalidListingAddress(_)) - )); - assert!(matches!( - super::parse_public_listing_addr(format!("30403:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg")), - Err(super::RadrootsOrderCanonicalizationError::InvalidListingKind) - )); - assert!(matches!( - super::parse_public_listing_addr(format!("30023:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg")), - Err(super::RadrootsOrderCanonicalizationError::InvalidListingKind) - )); - - assert!(matches!( super::canonicalize_order_request_for_signer(request_record().payload, SELLER), Err(super::RadrootsOrderCanonicalizationError::InvalidBuyerSigner) )); @@ -2714,15 +2662,6 @@ mod tests { Err(super::RadrootsOrderCanonicalizationError::InvalidSellerListing) )); - let mut invalid_kind = request_record().payload; - invalid_kind.listing_addr = - RadrootsListingAddress::parse(format!("30403:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg")) - .expect("retired listing draft address"); - assert!(matches!( - super::canonicalize_order_request_for_signer(invalid_kind, BUYER), - Err(super::RadrootsOrderCanonicalizationError::InvalidListingKind) - )); - let mut missing_items = request_record().payload; missing_items.items.clear(); assert!(matches!( @@ -2739,6 +2678,63 @@ mod tests { } #[test] + fn classified_listing_address_rejects_wrong_kind_before_typed_order_construction() { + let raw = format!("30403:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg"); + + assert!(matches!( + RadrootsClassifiedListingAddress::parse(&raw), + Err(radroots_event::ids::RadrootsIdParseError::UnexpectedKind { + expected: KIND_CLASSIFIED_LISTING, + actual: 30403, + }) + )); + } + + #[cfg(feature = "serde_json")] + #[test] + fn order_request_deserialization_rejects_wrong_listing_kind() { + let mut value = serde_json::to_value(request_record().payload).expect("serialize request"); + value["listing_addr"] = + serde_json::Value::String(format!("30403:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg")); + + let error = serde_json::from_value::<RadrootsOrderRequest>(value) + .expect_err("wrong listing kind must fail before constructing an order request"); + let message = error.to_string(); + assert!(message.contains("kind 30403")); + assert!(message.contains("required kind 30402")); + } + + #[cfg(feature = "serde_json")] + #[test] + fn order_event_codec_rejects_wrong_listing_kind_before_typed_record() { + let wrong_addr = format!("30403:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg"); + let mut parts = + order_request_event_build(&listing_event_ptr(), &request_record().payload).unwrap(); + let mut envelope: serde_json::Value = + serde_json::from_str(&parts.content).expect("request envelope"); + envelope["listing_addr"] = serde_json::Value::String(wrong_addr.clone()); + parts.content = serde_json::to_string(&envelope).expect("mutated request envelope"); + let listing_tag = parts + .tags + .iter_mut() + .find(|tag| tag.first().is_some_and(|name| name == "a")) + .expect("listing address tag"); + listing_tag[1] = wrong_addr; + + assert!(matches!( + super::order_event_record_from_event(&event_from_parts(17, BUYER, parts)), + Err(super::RadrootsOrderEventDecodeError::Envelope( + radroots_event_codec::order::RadrootsOrderEnvelopeParseError::InvalidListingAddr( + radroots_event::ids::RadrootsIdParseError::UnexpectedKind { + expected: KIND_CLASSIFIED_LISTING, + actual: 30403, + } + ) + )) + )); + } + + #[test] fn canonicalize_order_decision_reports_signer_and_decision_errors() { let canonical = super::canonicalize_order_decision_for_signer(accepted_decision().payload, SELLER) @@ -2931,21 +2927,21 @@ mod tests { assert_eq!(issue_event_id, &event_id(7)); let mut inventory_issues = [ - RadrootsListingInventoryAccountingIssue::OverReserved { + RadrootsOperationalListingInventoryAccountingIssue::OverReserved { bin_id: bin_id("bin-2"), available_count: 1, reserved_count: 2, event_ids: vec![event_id(8)], }, - RadrootsListingInventoryAccountingIssue::UnknownInventoryBin { + RadrootsOperationalListingInventoryAccountingIssue::UnknownInventoryBin { bin_id: bin_id("bin-1"), event_ids: vec![event_id(7)], }, - RadrootsListingInventoryAccountingIssue::ArithmeticOverflow { + RadrootsOperationalListingInventoryAccountingIssue::ArithmeticOverflow { bin_id: bin_id("bin-3"), event_ids: vec![event_id(6)], }, - RadrootsListingInventoryAccountingIssue::InvalidOrder { + RadrootsOperationalListingInventoryAccountingIssue::InvalidOrder { order_id: order_id("order-1"), event_ids: vec![event_id(5)], }, @@ -2953,31 +2949,31 @@ mod tests { inventory_issues.sort_by(super::inventory_issue_sort_key); assert!(matches!( inventory_issues[0], - RadrootsListingInventoryAccountingIssue::InvalidOrder { .. } + RadrootsOperationalListingInventoryAccountingIssue::InvalidOrder { .. } )); assert!(matches!( inventory_issues[1], - RadrootsListingInventoryAccountingIssue::ArithmeticOverflow { .. } + RadrootsOperationalListingInventoryAccountingIssue::ArithmeticOverflow { .. } )); assert!(matches!( inventory_issues[2], - RadrootsListingInventoryAccountingIssue::UnknownInventoryBin { .. } + RadrootsOperationalListingInventoryAccountingIssue::UnknownInventoryBin { .. } )); assert!(matches!( inventory_issues[3], - RadrootsListingInventoryAccountingIssue::OverReserved { .. } + RadrootsOperationalListingInventoryAccountingIssue::OverReserved { .. } )); let mut tied_inventory_issues = [ - RadrootsListingInventoryAccountingIssue::UnknownInventoryBin { + RadrootsOperationalListingInventoryAccountingIssue::UnknownInventoryBin { bin_id: bin_id("bin-2"), event_ids: vec![event_id(9)], }, - RadrootsListingInventoryAccountingIssue::UnknownInventoryBin { + RadrootsOperationalListingInventoryAccountingIssue::UnknownInventoryBin { bin_id: bin_id("bin-1"), event_ids: vec![event_id(8)], }, - RadrootsListingInventoryAccountingIssue::UnknownInventoryBin { + RadrootsOperationalListingInventoryAccountingIssue::UnknownInventoryBin { bin_id: bin_id("bin-1"), event_ids: vec![event_id(7)], }, @@ -3018,9 +3014,6 @@ mod tests { RadrootsOrderIssue::RequestAuthorMismatch { event_id: id.clone(), }, - RadrootsOrderIssue::RequestListingAddressInvalid { - event_id: id.clone(), - }, RadrootsOrderIssue::RequestSellerListingMismatch { event_id: id.clone(), }, @@ -3042,9 +3035,6 @@ mod tests { RadrootsOrderIssue::DecisionSellerMismatch { event_id: id.clone(), }, - RadrootsOrderIssue::DecisionListingAddressInvalid { - event_id: id.clone(), - }, RadrootsOrderIssue::DecisionListingMismatch { event_id: id.clone(), }, @@ -3087,9 +3077,6 @@ mod tests { RadrootsOrderIssue::CancellationSellerMismatch { event_id: id.clone(), }, - RadrootsOrderIssue::CancellationListingAddressInvalid { - event_id: id.clone(), - }, RadrootsOrderIssue::CancellationListingMismatch { event_id: id.clone(), }, @@ -3169,19 +3156,19 @@ mod tests { fn inventory_issue_helpers_cover_all_issue_variants() { let id = event_id(9); let issues = vec![ - RadrootsListingInventoryAccountingIssue::InvalidOrder { + RadrootsOperationalListingInventoryAccountingIssue::InvalidOrder { order_id: order_id("order-1"), event_ids: vec![id.clone()], }, - RadrootsListingInventoryAccountingIssue::ArithmeticOverflow { + RadrootsOperationalListingInventoryAccountingIssue::ArithmeticOverflow { bin_id: bin_id("bin-1"), event_ids: vec![id.clone()], }, - RadrootsListingInventoryAccountingIssue::UnknownInventoryBin { + RadrootsOperationalListingInventoryAccountingIssue::UnknownInventoryBin { bin_id: bin_id("bin-2"), event_ids: vec![id.clone()], }, - RadrootsListingInventoryAccountingIssue::OverReserved { + RadrootsOperationalListingInventoryAccountingIssue::OverReserved { bin_id: bin_id("bin-3"), available_count: 1, reserved_count: 2, @@ -3252,11 +3239,11 @@ mod tests { #[test] fn inventory_accounting_private_helpers_cover_merge_sort_and_overflow_paths() { let (bins, issues) = super::normalized_listing_inventory_bins(vec![ - RadrootsListingInventoryBinAvailability { + RadrootsOperationalListingInventoryBinAvailability { bin_id: bin_id("bin-1"), available_count: 1, }, - RadrootsListingInventoryBinAvailability { + RadrootsOperationalListingInventoryBinAvailability { bin_id: bin_id("bin-1"), available_count: 2, }, @@ -3265,7 +3252,7 @@ mod tests { assert_eq!(bins[0].available_count, 3); assert_eq!(bins[0].remaining_count, 3); - let mut overflow_bin = super::RadrootsListingInventoryBinAccounting { + let mut overflow_bin = super::RadrootsOperationalListingInventoryBinAccounting { bin_id: bin_id("bin-overflow"), available_count: u64::MAX, pending_reserved_count: u64::MAX, @@ -3285,13 +3272,13 @@ mod tests { ); assert_inventory_issue_kind( &overflow_issues, - RadrootsListingInventoryAccountingIssue::ArithmeticOverflow { + RadrootsOperationalListingInventoryAccountingIssue::ArithmeticOverflow { bin_id: bin_id("bin-overflow"), event_ids: Vec::new(), }, ); - let mut sorting_bin = super::RadrootsListingInventoryBinAccounting { + let mut sorting_bin = super::RadrootsOperationalListingInventoryBinAccounting { bin_id: bin_id("bin-sort"), available_count: 1, pending_reserved_count: 2, @@ -3299,34 +3286,34 @@ mod tests { remaining_count: 1, over_reserved: false, pending_orders: vec![ - super::RadrootsListingInventoryOrderReservation { + super::RadrootsOperationalListingInventoryOrderReservation { order_id: order_id("order-2"), agreement_event_id: event_id(92), bin_count: 1, }, - super::RadrootsListingInventoryOrderReservation { + super::RadrootsOperationalListingInventoryOrderReservation { order_id: order_id("order-1"), agreement_event_id: event_id(91), bin_count: 1, }, - super::RadrootsListingInventoryOrderReservation { + super::RadrootsOperationalListingInventoryOrderReservation { order_id: order_id("order-1"), agreement_event_id: event_id(90), bin_count: 1, }, ], committed_orders: vec![ - super::RadrootsListingInventoryOrderReservation { + super::RadrootsOperationalListingInventoryOrderReservation { order_id: order_id("order-2"), agreement_event_id: event_id(95), bin_count: 1, }, - super::RadrootsListingInventoryOrderReservation { + super::RadrootsOperationalListingInventoryOrderReservation { order_id: order_id("order-1"), agreement_event_id: event_id(94), bin_count: 1, }, - super::RadrootsListingInventoryOrderReservation { + super::RadrootsOperationalListingInventoryOrderReservation { order_id: order_id("order-1"), agreement_event_id: event_id(93), bin_count: 1, @@ -3355,7 +3342,7 @@ mod tests { ); assert_inventory_issue_kind( &finish_issues, - RadrootsListingInventoryAccountingIssue::OverReserved { + RadrootsOperationalListingInventoryAccountingIssue::OverReserved { bin_id: bin_id("bin-sort"), available_count: 1, reserved_count: 2, @@ -3468,12 +3455,6 @@ mod tests { }, ); assert_request_issue( - |request| request.payload.listing_addr = retired_listing_addr(), - RadrootsOrderIssue::RequestListingAddressInvalid { - event_id: event_id(1), - }, - ); - assert_request_issue( |request| request.payload.seller_pubkey = public_key(OTHER), RadrootsOrderIssue::RequestSellerListingMismatch { event_id: event_id(1), @@ -3530,12 +3511,6 @@ mod tests { }, ); assert_decision_issue( - |decision| decision.payload.listing_addr = retired_listing_addr(), - RadrootsOrderIssue::DecisionListingAddressInvalid { - event_id: event_id(2), - }, - ); - assert_decision_issue( |decision| decision.payload.listing_addr = other_seller_listing_addr(), RadrootsOrderIssue::DecisionListingMismatch { event_id: event_id(2), @@ -3607,12 +3582,6 @@ mod tests { }, ); assert_cancellation_issue( - |cancellation| cancellation.payload.listing_addr = retired_listing_addr(), - RadrootsOrderIssue::CancellationListingAddressInvalid { - event_id: event_id(5), - }, - ); - assert_cancellation_issue( |cancellation| cancellation.payload.listing_addr = other_seller_listing_addr(), RadrootsOrderIssue::CancellationListingMismatch { event_id: event_id(5), @@ -3715,20 +3684,20 @@ mod tests { terminal_cancellation.root_event_id = event_id(30); terminal_cancellation.payload.order_id = order_id("order-3"); - let projection = reduce_listing_inventory_accounting( + let projection = reduce_operational_listing_inventory_accounting( &listing_addr(), &event_id(9), - RadrootsListingInventoryAccountingInputs { + RadrootsOperationalListingInventoryAccountingInputs { bins: vec![ - RadrootsListingInventoryBinAvailability { + RadrootsOperationalListingInventoryBinAvailability { bin_id: bin_id("bin-1"), available_count: 1, }, - RadrootsListingInventoryBinAvailability { + RadrootsOperationalListingInventoryBinAvailability { bin_id: bin_id("bin-overflow"), available_count: u64::MAX, }, - RadrootsListingInventoryBinAvailability { + RadrootsOperationalListingInventoryBinAvailability { bin_id: bin_id("bin-overflow"), available_count: 1, }, @@ -3748,21 +3717,21 @@ mod tests { assert_eq!(projection.cancelled_order_ids, vec![order_id("order-3")]); assert_inventory_issue_kind( &projection.issues, - RadrootsListingInventoryAccountingIssue::ArithmeticOverflow { + RadrootsOperationalListingInventoryAccountingIssue::ArithmeticOverflow { bin_id: bin_id("bin-overflow"), event_ids: Vec::new(), }, ); assert_inventory_issue_kind( &projection.issues, - RadrootsListingInventoryAccountingIssue::UnknownInventoryBin { + RadrootsOperationalListingInventoryAccountingIssue::UnknownInventoryBin { bin_id: bin_id("bin-missing"), event_ids: Vec::new(), }, ); assert_inventory_issue_kind( &projection.issues, - RadrootsListingInventoryAccountingIssue::OverReserved { + RadrootsOperationalListingInventoryAccountingIssue::OverReserved { bin_id: bin_id("bin-1"), available_count: 0, reserved_count: 0, @@ -3770,11 +3739,11 @@ mod tests { }, ); - let invalid_without_request = reduce_listing_inventory_accounting( + let invalid_without_request = reduce_operational_listing_inventory_accounting( &listing_addr(), &event_id(9), - RadrootsListingInventoryAccountingInputs { - bins: Vec::<RadrootsListingInventoryBinAvailability>::new(), + RadrootsOperationalListingInventoryAccountingInputs { + bins: Vec::<RadrootsOperationalListingInventoryBinAvailability>::new(), requests: Vec::<RadrootsOrderRequestRecord>::new(), decisions: vec![accepted_decision()], cancellations: Vec::<RadrootsOrderCancellationRecord>::new(), @@ -3783,7 +3752,7 @@ mod tests { assert_eq!(invalid_without_request.invalid_event_ids, vec![event_id(2)]); assert_inventory_issue_kind( &invalid_without_request.issues, - RadrootsListingInventoryAccountingIssue::InvalidOrder { + RadrootsOperationalListingInventoryAccountingIssue::InvalidOrder { order_id: order_id("order-1"), event_ids: Vec::new(), }, @@ -3791,11 +3760,11 @@ mod tests { let mut duplicate_request = request_record(); duplicate_request.event_id = event_id(11); - let invalid_duplicate_requests = reduce_listing_inventory_accounting( + let invalid_duplicate_requests = reduce_operational_listing_inventory_accounting( &listing_addr(), &event_id(9), - RadrootsListingInventoryAccountingInputs { - bins: Vec::<RadrootsListingInventoryBinAvailability>::new(), + RadrootsOperationalListingInventoryAccountingInputs { + bins: Vec::<RadrootsOperationalListingInventoryBinAvailability>::new(), requests: vec![request_record(), duplicate_request], decisions: Vec::<RadrootsOrderDecisionRecord>::new(), cancellations: Vec::<RadrootsOrderCancellationRecord>::new(), @@ -3807,7 +3776,7 @@ mod tests { ); assert_inventory_issue_kind( &invalid_duplicate_requests.issues, - RadrootsListingInventoryAccountingIssue::InvalidOrder { + RadrootsOperationalListingInventoryAccountingIssue::InvalidOrder { order_id: order_id("order-1"), event_ids: Vec::new(), }, @@ -3930,11 +3899,11 @@ mod tests { #[test] fn inventory_accounting_reserves_only_accepted_agreements() { - let requested_projection = reduce_listing_inventory_accounting( + let requested_projection = reduce_operational_listing_inventory_accounting( &listing_addr(), &event_id(8), - RadrootsListingInventoryAccountingInputs { - bins: vec![RadrootsListingInventoryBinAvailability { + RadrootsOperationalListingInventoryAccountingInputs { + bins: vec![RadrootsOperationalListingInventoryBinAvailability { bin_id: bin_id("bin-1"), available_count: 3, }], @@ -3947,11 +3916,11 @@ mod tests { assert_eq!(requested_projection.bins[0].pending_reserved_count, 0); assert_eq!(requested_projection.bins[0].remaining_count, 3); - let projection = reduce_listing_inventory_accounting( + let projection = reduce_operational_listing_inventory_accounting( &listing_addr(), &event_id(9), - RadrootsListingInventoryAccountingInputs { - bins: vec![RadrootsListingInventoryBinAvailability { + RadrootsOperationalListingInventoryAccountingInputs { + bins: vec![RadrootsOperationalListingInventoryBinAvailability { bin_id: bin_id("bin-1"), available_count: 3, }], diff --git a/crates/trade/src/prelude.rs b/crates/trade/src/prelude.rs @@ -1,5 +1,5 @@ pub use crate::identity::*; -pub use crate::listing::*; +pub use crate::operational_listing::*; #[cfg(feature = "serde_json")] pub use crate::validation_receipt::*; pub use crate::workflow::*; diff --git a/crates/trade/src/projection.rs b/crates/trade/src/projection.rs @@ -4,9 +4,9 @@ use std::collections::{BTreeMap, BTreeSet}; use radroots_event::{ RadrootsEventEnvelope, RadrootsEventEnvelopeError, RadrootsEventEnvelopeParts, - ids::{RadrootsEventId, RadrootsIdParseError, RadrootsListingAddress, RadrootsOrderId}, - kinds::{KIND_TRADE_VALIDATION_RECEIPT, is_listing_kind, is_order_event_kind}, - listing::{RadrootsListingAvailability, RadrootsListingDeliveryMethod, RadrootsListingStatus}, + ids::{RadrootsEventId, RadrootsIdParseError, RadrootsClassifiedListingAddress, RadrootsOrderId}, + kinds::{KIND_TRADE_VALIDATION_RECEIPT, is_classified_listing_kind, is_order_event_kind}, + operational_listing::{RadrootsOperationalListingAvailability, RadrootsOperationalListingDeliveryMethod, RadrootsOperationalListingStatus}, order::RadrootsOrderEventType, tags::TAG_D, }; @@ -20,7 +20,7 @@ use thiserror::Error; use crate::{ identity::RadrootsTradeLocator, - listing::validation::{RadrootsTradeListing, validate_listing_event}, + operational_listing::validation::{RadrootsOperationalListingTradeProjection, validate_operational_listing_event}, order::{ RadrootsGroupedOrderEventRecords, RadrootsOrderEventDecodeError, RadrootsOrderEventRecord, RadrootsOrderProjectionQueryResult, RadrootsTradeLocatorProjectionQueryResult, @@ -68,7 +68,7 @@ pub enum RadrootsTradeProjectionError { #[error("stored listing event {event_id} failed validation: {source}")] ListingValidation { event_id: String, - source: radroots_event::trade_validation::RadrootsTradeValidationListingError, + source: radroots_event::trade_validation::RadrootsOperationalListingValidationError, }, #[error("stored order event {event_id} could not decode as an order record: {source}")] OrderDecode { @@ -96,7 +96,7 @@ pub enum RadrootsTradeProjectionError { source: RadrootsIdParseError, }, #[error("stored listing projection has invalid listing_addr {listing_addr}: {source}")] - ListingAddress { + ClassifiedListingAddress { listing_addr: String, source: RadrootsIdParseError, }, @@ -145,7 +145,7 @@ impl RadrootsProjectionRefreshRequest { #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct RadrootsProjectionRefreshReceipt { pub scanned_events: usize, - pub listing_upserts: usize, + pub operational_listing_upserts: usize, pub trade_upserts: usize, pub validation_receipts: usize, pub transport_observations: i64, @@ -153,8 +153,8 @@ pub struct RadrootsProjectionRefreshReceipt { } #[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsListingProjectionRow { - pub listing_addr: RadrootsListingAddress, +pub struct RadrootsOperationalListingProjectionRow { + pub listing_addr: RadrootsClassifiedListingAddress, pub listing_event_id: String, pub seller_pubkey: String, pub title: String, @@ -173,12 +173,12 @@ pub struct RadrootsListingProjectionRow { } #[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsListingSearchRequest { +pub struct RadrootsOperationalListingSearchRequest { pub query: String, pub limit: u32, } -impl RadrootsListingSearchRequest { +impl RadrootsOperationalListingSearchRequest { pub fn new(query: impl Into<String>) -> Self { Self { query: query.into(), @@ -220,16 +220,17 @@ pub async fn refresh_product_projections( receipt.last_event_seq = Some(stored_event.seq); receipt.transport_observations += transport_observation_count_for_event(store, &stored_event.event_id).await?; - if is_listing_kind(stored_event.kind) { + if is_classified_listing_kind(stored_event.kind) { let event = stored_event_to_nostr_event(stored_event)?; - let listing = validate_listing_event(&event).map_err(|source| { + let listing = validate_operational_listing_event(&event).map_err(|source| { RadrootsTradeProjectionError::ListingValidation { event_id: stored_event.event_id.clone(), source, } })?; - upsert_listing_projection(store, stored_event, &listing, updated_at_ms).await?; - receipt.listing_upserts += 1; + upsert_operational_listing_projection(store, stored_event, &listing, updated_at_ms) + .await?; + receipt.operational_listing_upserts += 1; } else if is_order_event_kind(stored_event.kind) { let event = stored_event_to_nostr_event(stored_event)?; let record = order_event_record_from_event(&event).map_err(|source| { @@ -278,12 +279,12 @@ pub async fn refresh_product_projections( Ok(receipt) } -pub async fn search_listing_projection( +pub async fn search_operational_listing_projection( store: &RadrootsEventStore, - request: &RadrootsListingSearchRequest, -) -> Result<Vec<RadrootsListingProjectionRow>, RadrootsTradeProjectionError> { + request: &RadrootsOperationalListingSearchRequest, +) -> Result<Vec<RadrootsOperationalListingProjectionRow>, RadrootsTradeProjectionError> { request.validate()?; - let rows = if let Some(query) = listing_fts_query(&request.query) { + let rows = if let Some(query) = operational_listing_fts_query(&request.query) { sqlx::query( "SELECT p.listing_addr, p.listing_event_id, p.seller_pubkey, p.title, p.description, p.product_type, p.price_amount, p.price_currency, p.inventory_available, p.delivery_method, p.locality_primary, p.locality_city, p.locality_region, p.locality_country, p.geohash5, p.updated_at_ms FROM listing_projection p JOIN listing_search_fts f ON f.listing_addr = p.listing_addr WHERE listing_search_fts MATCH ? ORDER BY bm25(listing_search_fts), p.updated_at_ms DESC, p.listing_addr LIMIT ?", ) @@ -299,7 +300,9 @@ pub async fn search_listing_projection( .fetch_all(store.pool()) .await? }; - rows.into_iter().map(listing_projection_row).collect() + rows.into_iter() + .map(operational_listing_projection_row) + .collect() } pub async fn trade_projection_query_for_order_id( @@ -343,10 +346,10 @@ pub async fn trade_projection_query_for_trade_locator( }) } -async fn upsert_listing_projection( +async fn upsert_operational_listing_projection( store: &RadrootsEventStore, stored_event: &RadrootsStoredEvent, - listing: &RadrootsTradeListing, + listing: &RadrootsOperationalListingTradeProjection, updated_at_ms: i64, ) -> Result<(), RadrootsTradeProjectionError> { let listing_json = serde_json::to_string(&listing.listing).map_err(|source| { @@ -538,7 +541,7 @@ async fn upsert_trade_projection_row( .bind(trade_workflow_status_label(&projection.status)) .bind(bool_i64(projection.lifecycle_terminal)) .bind(trade_rhi_state_label(&projection.status, projection.validation_receipt_event_id.as_ref())) - .bind(projection.listing_addr.as_ref().map(RadrootsListingAddress::as_str)) + .bind(projection.listing_addr.as_ref().map(RadrootsClassifiedListingAddress::as_str)) .bind(projection.buyer_pubkey.as_ref().map(|value| value.as_str())) .bind(projection.seller_pubkey.as_ref().map(|value| value.as_str())) .bind(projection.request_event_id.as_ref().map(|value| value.as_str())) @@ -799,17 +802,17 @@ async fn transport_observation_count_for_event( Ok(row.try_get("count")?) } -fn listing_projection_row( +fn operational_listing_projection_row( row: sqlx::sqlite::SqliteRow, -) -> Result<RadrootsListingProjectionRow, RadrootsTradeProjectionError> { +) -> Result<RadrootsOperationalListingProjectionRow, RadrootsTradeProjectionError> { let listing_addr = row.try_get::<String, _>("listing_addr")?; - let listing_addr = RadrootsListingAddress::parse(&listing_addr).map_err(|source| { - RadrootsTradeProjectionError::ListingAddress { + let listing_addr = RadrootsClassifiedListingAddress::parse(&listing_addr).map_err(|source| { + RadrootsTradeProjectionError::ClassifiedListingAddress { listing_addr: listing_addr.clone(), source, } })?; - Ok(RadrootsListingProjectionRow { + Ok(RadrootsOperationalListingProjectionRow { listing_addr, listing_event_id: row.try_get("listing_event_id")?, seller_pubkey: row.try_get("seller_pubkey")?, @@ -829,27 +832,27 @@ fn listing_projection_row( }) } -fn listing_availability_label(availability: &RadrootsListingAvailability) -> String { +fn listing_availability_label(availability: &RadrootsOperationalListingAvailability) -> String { match availability { - RadrootsListingAvailability::Window { .. } => "window".to_owned(), - RadrootsListingAvailability::Status { status } => match status { - RadrootsListingStatus::Active => "active".to_owned(), - RadrootsListingStatus::Sold => "sold".to_owned(), - RadrootsListingStatus::Other { value } => value.trim().to_owned(), + RadrootsOperationalListingAvailability::Window { .. } => "window".to_owned(), + RadrootsOperationalListingAvailability::Status { status } => match status { + RadrootsOperationalListingStatus::Active => "active".to_owned(), + RadrootsOperationalListingStatus::Sold => "sold".to_owned(), + RadrootsOperationalListingStatus::Other { value } => value.trim().to_owned(), }, } } -fn listing_delivery_method_label(delivery_method: &RadrootsListingDeliveryMethod) -> String { +fn listing_delivery_method_label(delivery_method: &RadrootsOperationalListingDeliveryMethod) -> String { match delivery_method { - RadrootsListingDeliveryMethod::Pickup => "pickup".to_owned(), - RadrootsListingDeliveryMethod::LocalDelivery => "local_delivery".to_owned(), - RadrootsListingDeliveryMethod::Shipping => "shipping".to_owned(), - RadrootsListingDeliveryMethod::Other { method } => method.trim().to_owned(), + RadrootsOperationalListingDeliveryMethod::Pickup => "pickup".to_owned(), + RadrootsOperationalListingDeliveryMethod::LocalDelivery => "local_delivery".to_owned(), + RadrootsOperationalListingDeliveryMethod::Shipping => "shipping".to_owned(), + RadrootsOperationalListingDeliveryMethod::Other { method } => method.trim().to_owned(), } } -fn listing_locality_search_text(listing: &RadrootsTradeListing) -> String { +fn listing_locality_search_text(listing: &RadrootsOperationalListingTradeProjection) -> String { [ Some(listing.location.primary.as_str()), listing.location.city.as_deref(), @@ -863,7 +866,7 @@ fn listing_locality_search_text(listing: &RadrootsTradeListing) -> String { .join(" ") } -fn listing_fts_query(query: &str) -> Option<String> { +fn operational_listing_fts_query(query: &str) -> Option<String> { let terms = query .split(|character: char| !character.is_alphanumeric()) .map(str::trim) @@ -921,10 +924,10 @@ mod tests { draft::RadrootsSignedEvent, farm::RadrootsFarmRef, ids::RadrootsOrderQuoteId, - kinds::KIND_LISTING, - listing::{ - RadrootsListing, RadrootsListingBin, RadrootsListingProduct, - RadrootsListingPublicLocation, + kinds::KIND_CLASSIFIED_LISTING, + operational_listing::{ + RadrootsOperationalListing, RadrootsOperationalListingBin, RadrootsOperationalListingProduct, + RadrootsOperationalListingPublicLocation, }, order::{ RadrootsOrderDecision, RadrootsOrderDecisionOutcome, RadrootsOrderEconomicItem, @@ -985,15 +988,15 @@ mod tests { raw.parse().expect("decimal") } - fn listing() -> RadrootsListing { - RadrootsListing { + fn listing() -> RadrootsOperationalListing { + RadrootsOperationalListing { d_tag: "AAAAAAAAAAAAAAAAAAAAAg".parse().expect("d tag"), published_at: Some(1_700_000_000), farm: RadrootsFarmRef { pubkey: SELLER.to_owned(), d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_owned(), }, - product: RadrootsListingProduct { + product: RadrootsOperationalListingProduct { key: "pea-shoots".to_owned(), title: "Pea shoots".to_owned(), category: "greens".to_owned(), @@ -1005,7 +1008,7 @@ mod tests { year: None, }, primary_bin_id: "bin-1".parse().expect("bin"), - bins: vec![RadrootsListingBin { + bins: vec![RadrootsOperationalListingBin { bin_id: "bin-1".parse().expect("bin"), quantity: RadrootsCoreQuantity::new(decimal("1"), RadrootsCoreUnit::Each), price_per_canonical_unit: RadrootsCoreQuantityPrice { @@ -1022,11 +1025,11 @@ mod tests { plot: None, discounts: None, inventory_available: Some(decimal("9")), - availability: Some(RadrootsListingAvailability::Status { - status: RadrootsListingStatus::Active, + availability: Some(RadrootsOperationalListingAvailability::Status { + status: RadrootsOperationalListingStatus::Active, }), - delivery_method: Some(RadrootsListingDeliveryMethod::Pickup), - location: Some(RadrootsListingPublicLocation { + delivery_method: Some(RadrootsOperationalListingDeliveryMethod::Pickup), + location: Some(RadrootsOperationalListingPublicLocation { primary: "Old Town".to_owned(), city: Some("Victoria".to_owned()), region: Some("BC".to_owned()), @@ -1038,7 +1041,7 @@ mod tests { } fn signed_listing_event() -> RadrootsSignedEvent { - let parts = radroots_event_codec::listing::encode::to_wire_parts(&listing()) + let parts = radroots_event_codec::operational_listing::encode::to_wire_parts(&listing()) .expect("listing parts"); sign_parts( parts.kind, @@ -1049,10 +1052,10 @@ mod tests { ) } - fn listing_addr(event: &RadrootsSignedEvent) -> RadrootsListingAddress { - RadrootsListingAddress::parse(format!( + fn listing_addr(event: &RadrootsSignedEvent) -> RadrootsClassifiedListingAddress { + RadrootsClassifiedListingAddress::parse(format!( "{}:{}:{}", - KIND_LISTING, + KIND_CLASSIFIED_LISTING, event.pubkey_str(), listing().d_tag )) @@ -1283,14 +1286,14 @@ mod tests { .await .expect("refresh"); assert_eq!(refresh.scanned_events, 4); - assert_eq!(refresh.listing_upserts, 1); + assert_eq!(refresh.operational_listing_upserts, 1); assert_eq!(refresh.trade_upserts, 1); assert_eq!(refresh.validation_receipts, 1); assert_eq!(refresh.transport_observations, 1); - let rows = search_listing_projection( + let rows = search_operational_listing_projection( &store, - &RadrootsListingSearchRequest::new("pea victoria").with_limit(10), + &RadrootsOperationalListingSearchRequest::new("pea victoria").with_limit(10), ) .await .expect("search"); diff --git a/crates/trade/src/workflow.rs b/crates/trade/src/workflow.rs @@ -1075,7 +1075,7 @@ fn projection_digest(_projection: &RadrootsTradeProjectionV1) -> String { mod tests { use super::*; use radroots_event::{ - ids::{RadrootsAddressableCoordinate, RadrootsInventoryBinId}, + ids::{RadrootsClassifiedListingAddress, RadrootsInventoryBinId}, trade::{ RADROOTS_TRADE_DECISION_CONTRACT_ID, RADROOTS_TRADE_PROPOSAL_CONTRACT_ID, RADROOTS_TRADE_REVISION_DECISION_CONTRACT_ID, @@ -1126,7 +1126,7 @@ mod tests { farm_id: dtag("farm-1"), lines: vec![RadrootsTradeCandidateLineV1 { line_id: dtag(&format!("line-{line_suffix}")), - listing_addr: RadrootsAddressableCoordinate::parse(format!( + listing_addr: RadrootsClassifiedListingAddress::parse(format!( "30402:{}:listing-{line_suffix}", hex_64('b') )) diff --git a/crates/trade_sp1_guest/src/lib.rs b/crates/trade_sp1_guest/src/lib.rs @@ -12,7 +12,7 @@ pub const RADROOTS_SP1_TRADE_PROTOCOL_VERSION: &str = "radroots.trade.v1"; pub const RADROOTS_SP1_TRADE_REDUCER_PROGRAM_HASH: &str = "0x3d8f7f463904d71f2d0d14b1551450756697e51c7b658e10c6d5c20a7bc61f08"; pub const RADROOTS_SP1_TRADE_ORDER_ACCEPTANCE_PROOF_TARGET: &str = "trade.order_acceptance.v1"; -pub const RADROOTS_SP1_TRADE_KIND_LISTING: u32 = 30402; +pub const RADROOTS_SP1_TRADE_KIND_CLASSIFIED_LISTING: u32 = 30402; pub const RADROOTS_SP1_TRADE_KIND_ORDER_REQUEST: u32 = 3422; pub const RADROOTS_SP1_TRADE_KIND_ORDER_DECISION: u32 = 3423; @@ -429,7 +429,7 @@ fn validate_event_evidence( &witness.listing_event_id, &witness.request.seller_pubkey, RadrootsSp1TradeEventEvidenceRole::Seller, - &[RADROOTS_SP1_TRADE_KIND_LISTING], + &[RADROOTS_SP1_TRADE_KIND_CLASSIFIED_LISTING], "listing", )?; @@ -842,7 +842,7 @@ struct StateRootMaterial<'a> { #[cfg_attr(coverage_nightly, coverage(off))] mod tests { use super::{ - RADROOTS_SP1_TRADE_KIND_LISTING, RADROOTS_SP1_TRADE_KIND_ORDER_DECISION, + RADROOTS_SP1_TRADE_KIND_CLASSIFIED_LISTING, RADROOTS_SP1_TRADE_KIND_ORDER_DECISION, RADROOTS_SP1_TRADE_KIND_ORDER_REQUEST, RADROOTS_SP1_TRADE_ORDER_ACCEPTANCE_PROOF_TARGET, RADROOTS_SP1_TRADE_PROTOCOL_VERSION, RADROOTS_SP1_TRADE_REDUCER_PROGRAM_HASH, RADROOTS_SP1_TRADE_WITNESS_VERSION, RadrootsSp1TradeCanonicalEventEvidence, @@ -908,7 +908,7 @@ mod tests { .to_string(), signer_pubkey: "1111111111111111111111111111111111111111111111111111111111111111" .to_string(), - kind: RADROOTS_SP1_TRADE_KIND_LISTING, + kind: RADROOTS_SP1_TRADE_KIND_CLASSIFIED_LISTING, canonical_event_hash: "0x1010101010101010101010101010101010101010101010101010101010101010".to_string(), signature_hash: diff --git a/crates/trade_sp1_host/src/lib.rs b/crates/trade_sp1_host/src/lib.rs @@ -1782,7 +1782,7 @@ mod tests { verify_validation_receipt_event, }; use radroots_trade_sp1_guest::{ - RADROOTS_SP1_TRADE_KIND_LISTING, RADROOTS_SP1_TRADE_KIND_ORDER_DECISION, + RADROOTS_SP1_TRADE_KIND_CLASSIFIED_LISTING, RADROOTS_SP1_TRADE_KIND_ORDER_DECISION, RADROOTS_SP1_TRADE_KIND_ORDER_REQUEST, RADROOTS_SP1_TRADE_ORDER_ACCEPTANCE_PROOF_TARGET, RADROOTS_SP1_TRADE_PROTOCOL_VERSION, RADROOTS_SP1_TRADE_REDUCER_PROGRAM_HASH, RADROOTS_SP1_TRADE_WITNESS_VERSION, RadrootsSp1TradeCanonicalEventEvidence, @@ -1859,7 +1859,7 @@ mod tests { .to_string(), signer_pubkey: "1111111111111111111111111111111111111111111111111111111111111111" .to_string(), - kind: RADROOTS_SP1_TRADE_KIND_LISTING, + kind: RADROOTS_SP1_TRADE_KIND_CLASSIFIED_LISTING, canonical_event_hash: "0x1010101010101010101010101010101010101010101010101010101010101010".to_string(), signature_hash: diff --git a/dto_bindgen.toml b/dto_bindgen.toml @@ -75,7 +75,7 @@ source_files = [ "crates/event/src/lib.rs", "crates/event/src/list.rs", "crates/event/src/list_set.rs", - "crates/event/src/listing.rs", + "crates/event/src/operational_listing.rs", "crates/event/src/message.rs", "crates/event/src/message_file.rs", "crates/event/src/order.rs", diff --git a/tools/xtask/src/contract.rs b/tools/xtask/src/contract.rs @@ -52,8 +52,8 @@ const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 9] = [ "crates/event_codec/tests/fixtures/calendar_radroots_profile.v1.json", ), ( - "contracts/conformance/vectors/listing/parse_event.v1.json", - "crates/event_codec/tests/fixtures/listing_parse_event.v1.json", + "contracts/conformance/vectors/operational_listing/parse_event.v1.json", + "crates/event_codec/tests/fixtures/operational_listing_parse_event.v1.json", ), ( "contracts/conformance/vectors/profile/metadata.v1.json", @@ -1541,14 +1541,14 @@ const RESOURCE_CAP_WITNESSES: [EventBoundarySourceWitness; 2] = [ }, ]; -const LISTING_WITNESSES: [EventBoundarySourceWitness; 2] = [ +const OPERATIONAL_LISTING_WITNESSES: [EventBoundarySourceWitness; 2] = [ EventBoundarySourceWitness { - relative_path: "crates/event/src/listing.rs", - required_fragments: &["pub struct RadrootsListing"], + relative_path: "crates/event/src/operational_listing.rs", + required_fragments: &["pub struct RadrootsOperationalListing"], }, EventBoundarySourceWitness { relative_path: "crates/event/src/kinds.rs", - required_fragments: &["pub const KIND_LISTING: u32 = 30402;"], + required_fragments: &["pub const KIND_CLASSIFIED_LISTING: u32 = 30402;"], }, ]; @@ -2028,15 +2028,15 @@ const CANONICAL_EVENT_BOUNDARY_EXPECTATIONS: [EventBoundaryExpectation; 41] = [ witnesses: &RESOURCE_CAP_WITNESSES, }, EventBoundaryExpectation { - domain: "listing", + domain: "operational_listing", kind: "30402", - radroots_type: "RadrootsListing", + radroots_type: "RadrootsOperationalListing", rpc_methods: &[ - "events.listing.publish", - "events.listing.list", - "events.listing.get", + "events.operational_listing.publish", + "events.operational_listing.list", + "events.operational_listing.get", ], - witnesses: &LISTING_WITNESSES, + witnesses: &OPERATIONAL_LISTING_WITNESSES, }, EventBoundaryExpectation { domain: "dvm_request", @@ -6463,7 +6463,7 @@ version = "1.0.0" source = "synthetic" [public] -domains = ["profile", "farm", "listing", "trade"] +domains = ["profile", "farm", "operational_listing", "trade"] [shared_types] public = [ @@ -6473,11 +6473,11 @@ public = [ "RadrootsEventEnvelope", "RadrootsEventRef", "RadrootsEventPtr", - "RadrootsListingAddress", + "RadrootsClassifiedListingAddress", "RadrootsAuthoredProfile", "RadrootsInboundProfileMetadata", "RadrootsFarm", - "RadrootsListing", + "RadrootsOperationalListing", ] [errors] @@ -6505,23 +6505,23 @@ rust_types = ["radroots_event::profile::RadrootsAuthoredProfile"] [operations.profile_build_authored_draft.conformance] vector = "contracts/conformance/vectors/profile/metadata.v1.json" -[operations.listing_build_draft] -domain = "listing" -id = "listing.build_draft" +[operations.operational_listing_build_draft] +domain = "operational_listing" +id = "operational_listing.build_draft" stability = "beta" -inputs = ["RadrootsListing"] +inputs = ["RadrootsOperationalListing"] outputs = ["RadrootsNip01EventWireParts"] error_class = "encode_error" deterministic = true signing = "native" transport = "native" -[operations.listing_build_draft.implementation] +[operations.operational_listing_build_draft.implementation] rust_modules = ["crates/core/src/unit.rs"] -rust_types = ["radroots_event::listing::RadrootsListing"] +rust_types = ["radroots_event::operational_listing::RadrootsOperationalListing"] -[operations.listing_build_draft.conformance] -vector = "contracts/conformance/vectors/listing/build_draft.v1.json" +[operations.operational_listing_build_draft.conformance] +vector = "contracts/conformance/vectors/operational_listing/build_draft.v1.json" "#, ); write_file( @@ -6584,15 +6584,15 @@ vector = "contracts/conformance/vectors/listing/build_draft.v1.json" .join("contracts") .join("conformance") .join("vectors") - .join("listing") + .join("operational_listing") .join("build_draft.v1.json"), r#"{ - "suite": "listing", + "suite": "operational_listing", "contract_version": "1.0.0", "vectors": [ { - "id": "listing_build_draft_minimal_001", - "kind": "listing.build_draft", + "id": "operational_listing_build_draft_minimal_001", + "kind": "operational_listing.build_draft", "input": {}, "expected": {} } diff --git a/tools/xtask/src/hygiene.rs b/tools/xtask/src/hygiene.rs @@ -15,6 +15,8 @@ const BINDING_DEPENDENCIES: &[&str] = &[ const RETIRED_PROTOCOL_EVENT_SURFACE_PATTERNS: &[&str] = &[ "KIND_LISTING_DRAFT", + "KIND_CLASSIFIED_LISTING_DRAFT", + "KIND_OPERATIONAL_LISTING_DRAFT", "KIND_ORDER_REVISION", "KIND_TRADE_QUESTION", "KIND_TRADE_ANSWER", @@ -28,9 +30,15 @@ const RETIRED_PROTOCOL_EVENT_SURFACE_PATTERNS: &[&str] = &[ "KIND_TRADE_TRANSITION_PROOF_REQUEST", "KIND_TRADE_TRANSITION_PROOF_RESULT", "RADROOTS_SP1_TRADE_KIND_LISTING_DRAFT", + "RADROOTS_SP1_TRADE_KIND_CLASSIFIED_LISTING_DRAFT", + "RADROOTS_SP1_TRADE_KIND_OPERATIONAL_LISTING_DRAFT", "RadrootsListingDraft", + "RadrootsClassifiedListingDraft", + "RadrootsOperationalListingDraft", "RadrootsCanonicalListingDraft", "RadrootsListingDraftError", + "RadrootsClassifiedListingDraftError", + "RadrootsOperationalListingDraftError", "RadrootsOrderRevision", "RadrootsOrderRevisionId", "RadrootsTradeValidationListingRequest", @@ -67,6 +75,53 @@ const RETIRED_PROTOCOL_EVENT_SURFACE_ALLOWED_PATHS: &[&str] = &[ "tools/xtask/src/hygiene.rs", ]; +const RETIRED_LISTING_ALIAS_IDENTIFIER_TOKENS: &[&str] = &[ + "KIND_LISTING", + "LISTING_EVENT_KINDS", + "is_listing_kind", + "is_listing_event_kind", + "RADROOTS_SP1_TRADE_KIND_LISTING", + "ListingTagOptions", + "ListingDecodeError", + "ListingParseError", + "ListingProjection", + "ListingInventoryAccounting", + "ListingAddress", + "ListingSnapshot", + "RADROOTS_LISTING_PRODUCT_TAG_KEYS", + "RadrootsCanonicalListingEdit", + "RadrootsPublicListingAddress", + "RadrootsPublicListingAddressError", + "RadrootsTradeListing", + "RadrootsTradeListingSubtotal", + "RadrootsTradeListingTotal", + "RadrootsTradeValidationListingError", + "farm_listings_list_set", + "farm_listings_list_set_from_listings", + "listing_from_event", + "listing_from_event_parts", + "listing_from_nostr_event", + "parse_listing_address", + "parse_listing_address_str", + "parse_listing_event", + "parse_public_listing_address", + "search_listing_projection", + "to_json_wire_parts_with_kind", + "validate_listing_event", + "listing_tags", + "listing_tags_with_options", + "listing_tags_full", + "listing_build_tags", + "decode_listing_from_event_parts", + "listing_markdown_content", + "build_listing_mutation_draft", + "canonicalize_listing_edit", + "reduce_listing_inventory_accounting", +]; + +const RETIRED_LISTING_ALIAS_IDENTIFIER_PREFIXES: &[&str] = &["RadrootsListing"]; +const RETIRED_LISTING_CONTRACT_ID: &str = "radroots.listing.published.v1"; + pub fn run(args: &[String], root: &Path) -> Result<(), String> { match args.first().map(String::as_str) { Some("forbidden-identifiers") => validate_forbidden_identifiers(root), @@ -111,6 +166,8 @@ pub fn validate_forbidden_identifiers(root: &Path) -> Result<(), String> { "RadrootsActiveOrder", "RadrootsActiveTrade", "RadrootsTradeListingParseError", + "RadrootsClassifiedListingTradeProjectionParseError", + "RadrootsOperationalListingTradeProjectionParseError", "RadrootsTradeDomain", "radroots_sdk::trade::", "TradeListingParseError", @@ -185,6 +242,7 @@ pub fn validate_forbidden_identifiers(root: &Path) -> Result<(), String> { &["tools/xtask/src/hygiene.rs"], &mut failures, ); + reject_retired_listing_aliases(root, &mut failures); reject_binding_dependencies(root, &mut failures); reject_forbidden_crate_paths(root, &mut failures); reject_existing_paths( @@ -229,6 +287,133 @@ pub fn validate_forbidden_identifiers(root: &Path) -> Result<(), String> { } } +fn reject_retired_listing_aliases(root: &Path, failures: &mut Vec<String>) { + let rel_roots = [ + PathBuf::from("crates"), + PathBuf::from("contracts"), + PathBuf::from("tools"), + PathBuf::from("build"), + PathBuf::from("dto_bindgen.toml"), + ]; + + for file in files_under(root, &rel_roots) { + let rel = display_path(root, &file); + if rel == "tools/xtask/src/hygiene.rs" { + continue; + } + if is_retired_listing_module_path(&rel) { + failures.push(format!( + "retired listing public aliases must not reappear: {rel}: legacy listing module path" + )); + } + + let Ok(content) = fs::read_to_string(&file) else { + continue; + }; + for (line_index, line) in content.lines().enumerate() { + if is_retired_listing_negative_guard(&rel, line) { + continue; + } + + for token in RETIRED_LISTING_ALIAS_IDENTIFIER_TOKENS { + if contains_identifier_token(line, token) { + failures.push(format!( + "retired listing public aliases must not reappear: {}:{}: {}", + rel, + line_index + 1, + line.trim() + )); + } + } + for prefix in RETIRED_LISTING_ALIAS_IDENTIFIER_PREFIXES { + if contains_identifier_prefix(line, prefix) { + failures.push(format!( + "retired listing public aliases must not reappear: {}:{}: {}", + rel, + line_index + 1, + line.trim() + )); + } + } + if line.contains(RETIRED_LISTING_CONTRACT_ID) { + failures.push(format!( + "retired listing public aliases must not reappear: {}:{}: {}", + rel, + line_index + 1, + line.trim() + )); + } + if is_listing_module_scope(&rel) && contains_retired_listing_module_reference(line) { + failures.push(format!( + "retired listing public aliases must not reappear: {}:{}: {}", + rel, + line_index + 1, + line.trim() + )); + } + } + } +} + +fn contains_identifier_token(line: &str, token: &str) -> bool { + line.match_indices(token).any(|(index, _)| { + let before = line[..index].chars().next_back(); + let after = line[index + token.len()..].chars().next(); + before.is_none_or(|ch| !is_identifier_continue(ch)) + && after.is_none_or(|ch| !is_identifier_continue(ch)) + }) +} + +fn contains_identifier_prefix(line: &str, prefix: &str) -> bool { + line.match_indices(prefix).any(|(index, _)| { + line[..index] + .chars() + .next_back() + .is_none_or(|ch| !is_identifier_continue(ch)) + }) +} + +fn is_identifier_continue(ch: char) -> bool { + ch.is_ascii_alphanumeric() || ch == '_' +} + +fn is_retired_listing_module_path(rel: &str) -> bool { + is_listing_module_scope(rel) + && Path::new(rel).components().any(|component| { + component.as_os_str() == "listing" + || Path::new(component.as_os_str()) + .file_stem() + .is_some_and(|stem| stem == "listing") + }) +} + +fn is_listing_module_scope(rel: &str) -> bool { + rel.starts_with("crates/event/src/") + || rel.starts_with("crates/event/tests/") + || rel.starts_with("crates/event_codec/src/") + || rel.starts_with("crates/event_codec/tests/") + || rel.starts_with("crates/trade/src/") + || rel.starts_with("crates/trade/tests/") + || rel.starts_with("contracts/conformance/vectors/") + || rel == "dto_bindgen.toml" +} + +fn contains_retired_listing_module_reference(line: &str) -> bool { + line.match_indices("listing").any(|(index, _)| { + let before = &line[..index]; + let after = line[index + "listing".len()..].chars().next(); + let starts_module_segment = + before.ends_with("::") || before.trim_end().ends_with("mod") || before.ends_with('/'); + starts_module_segment && after.is_none_or(|ch| !is_identifier_continue(ch)) + }) +} + +fn is_retired_listing_negative_guard(rel: &str, line: &str) -> bool { + (rel == "crates/event/src/dto.rs" && line.contains("\"RadrootsListingCancel\"")) + || (rel == "crates/event/src/contract.rs" + && line.contains("event_contract(\"radroots.listing.published.v1\").is_none()")) +} + fn reject_binding_dependencies(root: &Path, failures: &mut Vec<String>) { for file in manifest_files(root) { let rel = display_path(root, &file); @@ -404,7 +589,7 @@ fn is_raw_protocol_field(line: &str) -> bool { fn is_allowed_raw_boundary(struct_name: &str) -> bool { struct_name == "RadrootsOrderEnvelope" || struct_name == "RadrootsValidationReceiptTags" - || struct_name == "RadrootsTradeListing" + || struct_name == "RadrootsOperationalListingTradeProjection" || struct_name.ends_with("Projection") || struct_name.ends_with("Accounting") || struct_name.ends_with("Availability") @@ -528,7 +713,7 @@ mod tests { write_file( &root, "crates/event/src/kinds.rs", - "pub const KIND_TRADE_LISTING_ORDER: u64 = 1;\npub const KIND_TRADE_LISTING_VALIDATE_REQ: u64 = 5321;\npub const KIND_LISTING_DRAFT: u32 = 30403;\n", + "pub const KIND_TRADE_LISTING_ORDER: u64 = 1;\npub const KIND_TRADE_LISTING_VALIDATE_REQ: u64 = 5321;\npub const KIND_LISTING_DRAFT: u32 = 30403;\npub const KIND_CLASSIFIED_LISTING_DRAFT: u32 = 30403;\npub const KIND_LISTING: u32 = 30402;\n", ); write_file( &root, @@ -550,6 +735,7 @@ mod tests { assert!(err.contains("removed identifier 'tangle' must not reappear")); assert!(err.contains("removed trade and DVM kind constants must not reappear")); assert!(err.contains("retired V1 public event surfaces must not reappear")); + assert!(err.contains("retired listing public aliases must not reappear")); assert!(err.contains("wasm-bindgen")); assert!(err.contains("uniffi")); assert!(err.contains("crates/sql_wasm_bridge")); @@ -559,6 +745,62 @@ mod tests { } #[test] + fn listing_alias_guard_is_token_and_path_aware() { + let clean_root = unique_temp_dir("listing_alias_clean"); + write_file( + &clean_root, + "crates/event/src/kinds.rs", + "pub const CLASSIFIED_LISTING_EVENT_KINDS: [u32; 1] = [KIND_CLASSIFIED_LISTING];\n", + ); + write_file( + &clean_root, + "crates/event/src/operational_listing.rs", + "pub struct RadrootsOperationalListing;\n", + ); + validate_forbidden_identifiers(&clean_root).expect("new listing taxonomy is accepted"); + let _ = fs::remove_dir_all(clean_root); + + let dirty_root = unique_temp_dir("listing_alias_dirty"); + write_file( + &dirty_root, + "crates/event/src/listing.rs", + "pub const LISTING_EVENT_KINDS: [u32; 1] = [KIND_LISTING];\n", + ); + write_file( + &dirty_root, + "contracts/conformance/listing.json", + "{\"event_contract\":\"radroots.listing.published.v1\"}\n", + ); + write_file( + &dirty_root, + "crates/event_codec/tests/listing.rs", + "#[test]\nfn legacy_module_path() {}\n", + ); + write_file( + &dirty_root, + "contracts/conformance/vectors/listing/build_tags.v1.json", + "{}\n", + ); + write_file( + &dirty_root, + "crates/event_codec/tests/retired_aliases.rs", + "use radroots_trade::{RadrootsPublicListingAddress, listing_from_event, listing_tags_with_options};\nconst _: &str = RADROOTS_LISTING_PRODUCT_TAG_KEYS;\n", + ); + let err = + validate_forbidden_identifiers(&dirty_root).expect_err("old aliases are rejected"); + assert!(err.contains("retired listing public aliases must not reappear")); + assert!(err.contains("legacy listing module path")); + assert!(err.contains(RETIRED_LISTING_CONTRACT_ID)); + assert!(err.contains("crates/event_codec/tests/listing.rs")); + assert!(err.contains("RadrootsPublicListingAddress")); + assert!(err.contains("listing_from_event")); + assert!(err.contains("listing_tags_with_options")); + assert!(err.contains("contracts/conformance/vectors/listing/build_tags.v1.json")); + assert!(err.contains("RADROOTS_LISTING_PRODUCT_TAG_KEYS")); + let _ = fs::remove_dir_all(dirty_root); + } + + #[test] fn run_dispatches_forbidden_identifiers() { let root = unique_temp_dir("run"); write_file(