lib

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

commit 1c2e5bbe39282ebfb73d885f224e412248165589
parent 9ef5c5a3e1a12fb6e3989aa53c669608b9c2fd9f
Author: triesap <tyson@radroots.org>
Date:   Sun, 19 Jul 2026 02:54:31 +0000

tests: execute operational listing conformance

- replace symbolic codec vectors with exact Victoria listing wire cases
- verify classified addresses and signed NIP-01 trade projections
- govern packaged mirrors and operation case authority
- reject fixture drift in schema, geography, signatures, and errors

Diffstat:
Mcontracts/conformance/vectors/events/operational_listing_tags_full.v1.json | 174++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcontracts/conformance/vectors/operational_listing/build_draft.v1.json | 164++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcontracts/conformance/vectors/operational_listing/build_tags.v1.json | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcontracts/conformance/vectors/trade/parse_classified_listing_address.v1.json | 91++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcontracts/conformance/vectors/trade_validation/validate_operational_listing_event.v1.json | 214++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcontracts/operations.toml | 27+++++++++++++++++++++++----
Acrates/event_codec/tests/fixtures/operational_listing_build_draft.v1.json | 177+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/event_codec/tests/fixtures/operational_listing_build_tags.v1.json | 161+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/event_codec/tests/fixtures/operational_listing_tags_full.v1.json | 173+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/event_codec/tests/operational_listing_conformance.rs | 419+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/trade/tests/fixtures/parse_classified_listing_address.v1.json | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/trade/tests/fixtures/validate_operational_listing_event.v1.json | 222+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/trade/tests/operational_listing_conformance.rs | 316+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/xtask/src/contract.rs | 50++++++++++++++++++++++++++++++++++++++++----------
14 files changed, 2381 insertions(+), 54 deletions(-)

diff --git a/contracts/conformance/vectors/events/operational_listing_tags_full.v1.json b/contracts/conformance/vectors/events/operational_listing_tags_full.v1.json @@ -3,24 +3,170 @@ "contract_version": "1.0.0", "vectors": [ { - "id": "operational_listing_tags_full_minimal_001", + "id": "operational_listing_tags_full_victoria_carrots_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": [] + "listing": { + "d_tag": "AAAAAAAAAAAAAAAAAAAAAg", + "published_at": 1700000000, + "farm": { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "d_tag": "AAAAAAAAAAAAAAAAAAAAAA" + }, + "product": { + "key": "carrot-nantes", + "title": "Nantes Carrots", + "category": "produce", + "summary": "Fresh bunches harvested in Saanich" + }, + "primary_bin_id": "bunch", + "bins": [ + { + "bin_id": "bunch", + "quantity": { + "amount": "1", + "unit": "each" + }, + "price_per_canonical_unit": { + "amount": { + "amount": "4", + "currency": "CAD" + }, + "quantity": { + "amount": "1", + "unit": "each" + } + } + } + ], + "inventory_available": "24", + "availability": { + "kind": "status", + "amount": { + "status": { + "kind": "active" + } + } + }, + "delivery_method": { + "kind": "pickup" + }, + "location": { + "primary": "Saanich Peninsula", + "city": "Victoria", + "region": "BC", + "country": "CA", + "geohash": "c28hr" + } + } }, "expected": { - "tags": [] + "tags": [ + [ + "d", + "AAAAAAAAAAAAAAAAAAAAAg" + ], + [ + "p", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + [ + "a", + "30340:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:AAAAAAAAAAAAAAAAAAAAAA" + ], + [ + "key", + "carrot-nantes" + ], + [ + "title", + "Nantes Carrots" + ], + [ + "category", + "produce" + ], + [ + "summary", + "Fresh bunches harvested in Saanich" + ], + [ + "published_at", + "1700000000" + ], + [ + "radroots:primary_bin", + "bunch" + ], + [ + "radroots:bin", + "bunch", + "1", + "each" + ], + [ + "radroots:price", + "bunch", + "4", + "CAD", + "1", + "each" + ], + [ + "price", + "4", + "CAD" + ], + [ + "inventory", + "24" + ], + [ + "status", + "active" + ], + [ + "delivery", + "pickup" + ], + [ + "location", + "Saanich Peninsula", + "Victoria", + "BC", + "CA" + ], + [ + "g", + "c28hr" + ] + ] + } + }, + { + "id": "operational_listing_tags_full_empty_bins_002", + "kind": "operational_listing_tags_full", + "input": { + "listing": { + "d_tag": "AAAAAAAAAAAAAAAAAAAAAw", + "farm": { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "d_tag": "AAAAAAAAAAAAAAAAAAAAAA" + }, + "product": { + "key": "carrot-nantes", + "title": "Nantes Carrots", + "category": "produce" + }, + "primary_bin_id": "bunch", + "bins": [] + } + }, + "expected": { + "error": { + "code": "empty_required_field", + "field": "bins" + } } } ] diff --git a/contracts/conformance/vectors/operational_listing/build_draft.v1.json b/contracts/conformance/vectors/operational_listing/build_draft.v1.json @@ -3,29 +3,173 @@ "contract_version": "1.0.0", "vectors": [ { - "id": "operational_listing_build_draft_minimal_001", + "id": "operational_listing_build_draft_victoria_carrots_001", "kind": "operational_listing.build_draft", "input": { "listing": { "d_tag": "AAAAAAAAAAAAAAAAAAAAAg", + "published_at": 1700000000, "farm": { - "pubkey": "farm_pubkey", + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "d_tag": "AAAAAAAAAAAAAAAAAAAAAA" }, "product": { - "key": "sku", - "title": "widget", - "category": "tools" + "key": "carrot-nantes", + "title": "Nantes Carrots", + "category": "produce", + "summary": "Fresh bunches harvested in Saanich" }, - "primary_bin_id": "bin-1", - "bins": [] + "primary_bin_id": "bunch", + "bins": [ + { + "bin_id": "bunch", + "quantity": { + "amount": "1", + "unit": "each" + }, + "price_per_canonical_unit": { + "amount": { + "amount": "4", + "currency": "CAD" + }, + "quantity": { + "amount": "1", + "unit": "each" + } + } + } + ], + "inventory_available": "24", + "availability": { + "kind": "status", + "amount": { + "status": { + "kind": "active" + } + } + }, + "delivery_method": { + "kind": "pickup" + }, + "location": { + "primary": "Saanich Peninsula", + "city": "Victoria", + "region": "BC", + "country": "CA", + "geohash": "c28hr" + } } }, "expected": { "wire_parts": { - "kind": "classified_listing", - "content_shape": "operational_listing_markdown_or_empty", - "tags_shape": "operational_listing_tags_full" + "kind": 30402, + "content": "# Nantes Carrots\n\nFresh bunches harvested in Saanich", + "tags": [ + [ + "d", + "AAAAAAAAAAAAAAAAAAAAAg" + ], + [ + "p", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + [ + "a", + "30340:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:AAAAAAAAAAAAAAAAAAAAAA" + ], + [ + "key", + "carrot-nantes" + ], + [ + "title", + "Nantes Carrots" + ], + [ + "category", + "produce" + ], + [ + "summary", + "Fresh bunches harvested in Saanich" + ], + [ + "published_at", + "1700000000" + ], + [ + "radroots:primary_bin", + "bunch" + ], + [ + "radroots:bin", + "bunch", + "1", + "each" + ], + [ + "radroots:price", + "bunch", + "4", + "CAD", + "1", + "each" + ], + [ + "price", + "4", + "CAD" + ], + [ + "inventory", + "24" + ], + [ + "status", + "active" + ], + [ + "delivery", + "pickup" + ], + [ + "location", + "Saanich Peninsula", + "Victoria", + "BC", + "CA" + ], + [ + "g", + "c28hr" + ] + ] + } + } + }, + { + "id": "operational_listing_build_draft_empty_bins_002", + "kind": "operational_listing.build_draft", + "input": { + "listing": { + "d_tag": "AAAAAAAAAAAAAAAAAAAAAw", + "farm": { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "d_tag": "AAAAAAAAAAAAAAAAAAAAAA" + }, + "product": { + "key": "carrot-nantes", + "title": "Nantes Carrots", + "category": "produce" + }, + "primary_bin_id": "bunch", + "bins": [] + } + }, + "expected": { + "error": { + "code": "empty_required_field", + "field": "bins" } } } diff --git a/contracts/conformance/vectors/operational_listing/build_tags.v1.json b/contracts/conformance/vectors/operational_listing/build_tags.v1.json @@ -3,26 +3,158 @@ "contract_version": "1.0.0", "vectors": [ { - "id": "operational_listing_build_tags_minimal_001", + "id": "operational_listing_build_tags_victoria_carrots_001", "kind": "operational_listing.build_tags", "input": { "listing": { "d_tag": "AAAAAAAAAAAAAAAAAAAAAg", + "published_at": 1700000000, "farm": { - "pubkey": "farm_pubkey", + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "d_tag": "AAAAAAAAAAAAAAAAAAAAAA" }, "product": { - "key": "sku", - "title": "widget", - "category": "tools" + "key": "carrot-nantes", + "title": "Nantes Carrots", + "category": "produce", + "summary": "Fresh bunches harvested in Saanich" }, - "primary_bin_id": "bin-1", + "primary_bin_id": "bunch", + "bins": [ + { + "bin_id": "bunch", + "quantity": { + "amount": "1", + "unit": "each" + }, + "price_per_canonical_unit": { + "amount": { + "amount": "4", + "currency": "CAD" + }, + "quantity": { + "amount": "1", + "unit": "each" + } + } + } + ], + "inventory_available": "24", + "availability": { + "kind": "status", + "amount": { + "status": { + "kind": "active" + } + } + }, + "delivery_method": { + "kind": "pickup" + }, + "location": { + "primary": "Saanich Peninsula", + "city": "Victoria", + "region": "BC", + "country": "CA", + "geohash": "c28hr" + } + } + }, + "expected": { + "tags": [ + [ + "d", + "AAAAAAAAAAAAAAAAAAAAAg" + ], + [ + "p", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + [ + "a", + "30340:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:AAAAAAAAAAAAAAAAAAAAAA" + ], + [ + "key", + "carrot-nantes" + ], + [ + "title", + "Nantes Carrots" + ], + [ + "category", + "produce" + ], + [ + "summary", + "Fresh bunches harvested in Saanich" + ], + [ + "published_at", + "1700000000" + ], + [ + "radroots:primary_bin", + "bunch" + ], + [ + "radroots:bin", + "bunch", + "1", + "each" + ], + [ + "radroots:price", + "bunch", + "4", + "CAD", + "1", + "each" + ], + [ + "price", + "4", + "CAD" + ], + [ + "location", + "Saanich Peninsula", + "Victoria", + "BC", + "CA" + ], + [ + "g", + "c28hr" + ] + ] + } + }, + { + "id": "operational_listing_build_tags_empty_bins_002", + "kind": "operational_listing.build_tags", + "input": { + "listing": { + "d_tag": "AAAAAAAAAAAAAAAAAAAAAw", + "farm": { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "d_tag": "AAAAAAAAAAAAAAAAAAAAAA" + }, + "product": { + "key": "carrot-nantes", + "title": "Nantes Carrots", + "category": "produce" + }, + "primary_bin_id": "bunch", "bins": [] } }, "expected": { - "tags_shape": "operational_listing_tags" + "error": { + "code": "empty_required_field", + "field": "bins" + } } } ] diff --git a/contracts/conformance/vectors/trade/parse_classified_listing_address.v1.json b/contracts/conformance/vectors/trade/parse_classified_listing_address.v1.json @@ -3,13 +3,98 @@ "contract_version": "1.0.0", "vectors": [ { - "id": "trade_parse_classified_listing_address_minimal_001", - "kind": "trade.parse_classified_listing_address", + "id": "trade_parse_classified_listing_address_canonical_001", + "kind": "trade.parse_classified_listing_address.valid", "input": { "listing_addr": "30402:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:AAAAAAAAAAAAAAAAAAAAAg" }, "expected": { - "address_shape": "classified_listing_address" + "address": "30402:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:AAAAAAAAAAAAAAAAAAAAAg", + "kind": 30402, + "seller_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "listing_id": "AAAAAAAAAAAAAAAAAAAAAg" + } + }, + { + "id": "trade_parse_classified_listing_address_normalized_002", + "kind": "trade.parse_classified_listing_address.valid", + "input": { + "listing_addr": "+030402:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:AAAAAAAAAAAAAAAAAAAAAg" + }, + "expected": { + "address": "30402:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:AAAAAAAAAAAAAAAAAAAAAg", + "kind": 30402, + "seller_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "listing_id": "AAAAAAAAAAAAAAAAAAAAAg" + } + }, + { + "id": "trade_parse_classified_listing_address_wrong_kind_003", + "kind": "trade.parse_classified_listing_address.invalid", + "input": { + "listing_addr": "30403:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:AAAAAAAAAAAAAAAAAAAAAg" + }, + "expected": { + "error": { + "kind": "unexpected_kind", + "expected": 30402, + "actual": 30403 + }, + "message": "identifier kind 30403 does not match required kind 30402" + } + }, + { + "id": "trade_parse_classified_listing_address_malformed_format_004", + "kind": "trade.parse_classified_listing_address.invalid", + "input": { + "listing_addr": "30402" + }, + "expected": { + "error": { + "kind": "invalid_format" + }, + "message": "identifier has invalid format" + } + }, + { + "id": "trade_parse_classified_listing_address_short_pubkey_005", + "kind": "trade.parse_classified_listing_address.invalid", + "input": { + "listing_addr": "30402:not_hex:AAAAAAAAAAAAAAAAAAAAAg" + }, + "expected": { + "error": { + "kind": "invalid_length", + "expected": 64, + "actual": 7 + }, + "message": "identifier length 7 does not match required length 64" + } + }, + { + "id": "trade_parse_classified_listing_address_non_hex_pubkey_006", + "kind": "trade.parse_classified_listing_address.invalid", + "input": { + "listing_addr": "30402:gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg:AAAAAAAAAAAAAAAAAAAAAg" + }, + "expected": { + "error": { + "kind": "invalid_character" + }, + "message": "identifier contains an invalid character" + } + }, + { + "id": "trade_parse_classified_listing_address_invalid_d_tag_007", + "kind": "trade.parse_classified_listing_address.invalid", + "input": { + "listing_addr": "30402:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:bad d" + }, + "expected": { + "error": { + "kind": "invalid_character" + }, + "message": "identifier contains an invalid character" } } ] 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 @@ -3,17 +3,219 @@ "contract_version": "1.0.0", "vectors": [ { - "id": "trade_validation_validate_operational_listing_event_minimal_001", - "kind": "trade_validation.validate_operational_listing_event", + "id": "trade_validation_validate_operational_listing_event_valid_001", + "kind": "trade_validation.validate_operational_listing_event.valid", "input": { "event": { - "kind": "classified_listing", - "content_shape": "operational_listing_json_or_markdown", - "tags_shape": "operational_listing_tags_full" + "id": "6739ed38175521d1b8a64592ec3407332ee24449e1e7353fd8f721c0995b9d8f", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000000, + "kind": 30402, + "tags": [ + ["d", "AAAAAAAAAAAAAAAAAAAAAg"], + ["p", "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"], + ["a", "30340:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "carrot-nantes"], + ["title", "Nantes Carrots"], + ["category", "produce"], + ["summary", "Fresh bunches harvested in Saanich"], + ["published_at", "1700000000"], + ["radroots:primary_bin", "bunch"], + ["radroots:bin", "bunch", "1", "each"], + ["radroots:price", "bunch", "4", "CAD", "1", "each"], + ["price", "4", "CAD"], + ["inventory", "24"], + ["status", "active"], + ["delivery", "pickup"], + ["location", "Saanich Peninsula", "Victoria", "BC", "CA"], + ["g", "c28hr"] + ], + "content": "# Nantes Carrots\n\nFresh bunches harvested in Saanich", + "sig": "ef3413c4ac4be3f748fcb51b3e5b9b03c590f5f814dbd6ab3f2f2c26dbc87eb1347cefbe97abacce60f0c4530848695e766d3a950350c72089813b3318a3f944" } }, "expected": { - "validation_shape": "operational_listing_trade_projection" + "projection": { + "listing_id": "AAAAAAAAAAAAAAAAAAAAAg", + "listing_addr": "30402:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:AAAAAAAAAAAAAAAAAAAAAg", + "seller_pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "title": "Nantes Carrots", + "description": "Fresh bunches harvested in Saanich", + "product_type": "produce", + "primary_bin_id": "bunch", + "bin_quantity": { + "amount": "1", + "unit": "each" + }, + "unit": "each", + "unit_price": { + "amount": "4", + "currency": "CAD" + }, + "inventory_available": "24", + "availability": { + "kind": "status", + "amount": { + "status": { + "kind": "active" + } + } + }, + "location": { + "primary": "Saanich Peninsula", + "city": "Victoria", + "region": "BC", + "country": "CA", + "geohash": "c28hr" + }, + "delivery_method": { + "kind": "pickup" + }, + "listing": { + "d_tag": "AAAAAAAAAAAAAAAAAAAAAg", + "published_at": 1700000000, + "farm": { + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "AAAAAAAAAAAAAAAAAAAAAA" + }, + "product": { + "key": "carrot-nantes", + "title": "Nantes Carrots", + "category": "produce", + "summary": "Fresh bunches harvested in Saanich", + "process": null, + "lot": null, + "location": null, + "profile": null, + "year": null + }, + "primary_bin_id": "bunch", + "bins": [ + { + "bin_id": "bunch", + "quantity": { + "amount": "1", + "unit": "each" + }, + "price_per_canonical_unit": { + "amount": { + "amount": "4", + "currency": "CAD" + }, + "quantity": { + "amount": "1", + "unit": "each" + } + }, + "display_amount": null, + "display_unit": null, + "display_label": null, + "display_price": null, + "display_price_unit": null + } + ], + "resource_area": null, + "plot": null, + "discounts": null, + "inventory_available": "24", + "availability": { + "kind": "status", + "amount": { + "status": { + "kind": "active" + } + } + }, + "delivery_method": { + "kind": "pickup" + }, + "location": { + "primary": "Saanich Peninsula", + "city": "Victoria", + "region": "BC", + "country": "CA", + "geohash": "c28hr" + }, + "images": null + } + } + } + }, + { + "id": "trade_validation_validate_operational_listing_event_invalid_seller_002", + "kind": "trade_validation.validate_operational_listing_event.invalid", + "input": { + "event": { + "id": "294177fd3f698947e8f1be9f1f3429c87d87d17a643b1c3666674de7bbe6eb20", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000000, + "kind": 30402, + "tags": [ + ["d", "AAAAAAAAAAAAAAAAAAAAAg"], + ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], + ["a", "30340:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "carrot-nantes"], + ["title", "Nantes Carrots"], + ["category", "produce"], + ["summary", "Fresh bunches harvested in Saanich"], + ["published_at", "1700000000"], + ["radroots:primary_bin", "bunch"], + ["radroots:bin", "bunch", "1", "each"], + ["radroots:price", "bunch", "4", "CAD", "1", "each"], + ["price", "4", "CAD"], + ["inventory", "24"], + ["status", "active"], + ["delivery", "pickup"], + ["location", "Saanich Peninsula", "Victoria", "BC", "CA"], + ["g", "c28hr"] + ], + "content": "# Nantes Carrots\n\nFresh bunches harvested in Saanich", + "sig": "0b12724932817061d949d1d4543beaa90b27b08f09f63422871dda01ee692b26c4ff9ef66b31ea4500b76d1c8e45c2126c96b7dc17b8f242b29b97a5a7294b73" + } + }, + "expected": { + "error": { + "kind": "invalid_seller" + }, + "message": "listing author does not match farm pubkey" + } + }, + { + "id": "trade_validation_validate_operational_listing_event_missing_inventory_003", + "kind": "trade_validation.validate_operational_listing_event.invalid", + "input": { + "event": { + "id": "b2d934d5b9a089a500c7c2a76fb1486b4abf2dd4b9334e3dc00114884818e77a", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000000, + "kind": 30402, + "tags": [ + ["d", "AAAAAAAAAAAAAAAAAAAAAg"], + ["p", "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"], + ["a", "30340:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "carrot-nantes"], + ["title", "Nantes Carrots"], + ["category", "produce"], + ["summary", "Fresh bunches harvested in Saanich"], + ["published_at", "1700000000"], + ["radroots:primary_bin", "bunch"], + ["radroots:bin", "bunch", "1", "each"], + ["radroots:price", "bunch", "4", "CAD", "1", "each"], + ["price", "4", "CAD"], + ["status", "active"], + ["delivery", "pickup"], + ["location", "Saanich Peninsula", "Victoria", "BC", "CA"], + ["g", "c28hr"] + ], + "content": "# Nantes Carrots\n\nFresh bunches harvested in Saanich", + "sig": "3504a764b97f8fd2915281e8c110247979da5f62743967227711a08c08de5a87ad79500def9da29730e12175dbe656f75b75fe2dae4a4e25c7f989aecd3520ef" + } + }, + "expected": { + "error": { + "kind": "missing_inventory" + }, + "message": "missing listing inventory" } } ] diff --git a/contracts/operations.toml b/contracts/operations.toml @@ -1216,18 +1216,29 @@ domain = "trade" id = "trade.parse_classified_listing_address" stability = "beta" inputs = ["listing_addr"] -outputs = ["RadrootsClassifiedListingAddress"] +outputs = ["RadrootsClassifiedListingAddressParts"] error_class = "address_error" deterministic = true signing = "native" transport = "native" [operations.trade_parse_classified_listing_address.implementation] -rust_modules = ["crates/event/src/ids.rs"] -rust_types = ["radroots_event::ids::RadrootsClassifiedListingAddress"] +rust_modules = [ + "crates/event/src/ids.rs", + "crates/trade/src/operational_listing/mod.rs", +] +rust_types = [ + "radroots_event::ids::RadrootsClassifiedListingAddress", + "radroots_event::ids::RadrootsIdParseError", + "radroots_trade::operational_listing::RadrootsClassifiedListingAddressParts", +] [operations.trade_parse_classified_listing_address.conformance] vector = "contracts/conformance/vectors/trade/parse_classified_listing_address.v1.json" +case_kinds = [ + "trade.parse_classified_listing_address.valid", + "trade.parse_classified_listing_address.invalid", +] [operations.trade_reduce_records] domain = "trade" @@ -1262,14 +1273,22 @@ signing = "native" transport = "native" [operations.trade_validation_validate_operational_listing_event.implementation] -rust_modules = ["crates/trade/src/operational_listing/validation.rs"] +rust_modules = [ + "crates/event/src/trade_validation.rs", + "crates/trade/src/operational_listing/validation.rs", +] rust_types = [ "radroots_event::RadrootsEventEnvelope", + "radroots_event::trade_validation::RadrootsOperationalListingValidationError", "radroots_trade::operational_listing::validation::RadrootsOperationalListingTradeProjection", ] [operations.trade_validation_validate_operational_listing_event.conformance] vector = "contracts/conformance/vectors/trade_validation/validate_operational_listing_event.v1.json" +case_kinds = [ + "trade_validation.validate_operational_listing_event.valid", + "trade_validation.validate_operational_listing_event.invalid", +] [operations.knowledge_contract_manifest_json] domain = "knowledge" diff --git a/crates/event_codec/tests/fixtures/operational_listing_build_draft.v1.json b/crates/event_codec/tests/fixtures/operational_listing_build_draft.v1.json @@ -0,0 +1,177 @@ +{ + "suite": "operational_listing", + "contract_version": "1.0.0", + "vectors": [ + { + "id": "operational_listing_build_draft_victoria_carrots_001", + "kind": "operational_listing.build_draft", + "input": { + "listing": { + "d_tag": "AAAAAAAAAAAAAAAAAAAAAg", + "published_at": 1700000000, + "farm": { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "d_tag": "AAAAAAAAAAAAAAAAAAAAAA" + }, + "product": { + "key": "carrot-nantes", + "title": "Nantes Carrots", + "category": "produce", + "summary": "Fresh bunches harvested in Saanich" + }, + "primary_bin_id": "bunch", + "bins": [ + { + "bin_id": "bunch", + "quantity": { + "amount": "1", + "unit": "each" + }, + "price_per_canonical_unit": { + "amount": { + "amount": "4", + "currency": "CAD" + }, + "quantity": { + "amount": "1", + "unit": "each" + } + } + } + ], + "inventory_available": "24", + "availability": { + "kind": "status", + "amount": { + "status": { + "kind": "active" + } + } + }, + "delivery_method": { + "kind": "pickup" + }, + "location": { + "primary": "Saanich Peninsula", + "city": "Victoria", + "region": "BC", + "country": "CA", + "geohash": "c28hr" + } + } + }, + "expected": { + "wire_parts": { + "kind": 30402, + "content": "# Nantes Carrots\n\nFresh bunches harvested in Saanich", + "tags": [ + [ + "d", + "AAAAAAAAAAAAAAAAAAAAAg" + ], + [ + "p", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + [ + "a", + "30340:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:AAAAAAAAAAAAAAAAAAAAAA" + ], + [ + "key", + "carrot-nantes" + ], + [ + "title", + "Nantes Carrots" + ], + [ + "category", + "produce" + ], + [ + "summary", + "Fresh bunches harvested in Saanich" + ], + [ + "published_at", + "1700000000" + ], + [ + "radroots:primary_bin", + "bunch" + ], + [ + "radroots:bin", + "bunch", + "1", + "each" + ], + [ + "radroots:price", + "bunch", + "4", + "CAD", + "1", + "each" + ], + [ + "price", + "4", + "CAD" + ], + [ + "inventory", + "24" + ], + [ + "status", + "active" + ], + [ + "delivery", + "pickup" + ], + [ + "location", + "Saanich Peninsula", + "Victoria", + "BC", + "CA" + ], + [ + "g", + "c28hr" + ] + ] + } + } + }, + { + "id": "operational_listing_build_draft_empty_bins_002", + "kind": "operational_listing.build_draft", + "input": { + "listing": { + "d_tag": "AAAAAAAAAAAAAAAAAAAAAw", + "farm": { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "d_tag": "AAAAAAAAAAAAAAAAAAAAAA" + }, + "product": { + "key": "carrot-nantes", + "title": "Nantes Carrots", + "category": "produce" + }, + "primary_bin_id": "bunch", + "bins": [] + } + }, + "expected": { + "error": { + "code": "empty_required_field", + "field": "bins" + } + } + } + ] +} diff --git a/crates/event_codec/tests/fixtures/operational_listing_build_tags.v1.json b/crates/event_codec/tests/fixtures/operational_listing_build_tags.v1.json @@ -0,0 +1,161 @@ +{ + "suite": "operational_listing", + "contract_version": "1.0.0", + "vectors": [ + { + "id": "operational_listing_build_tags_victoria_carrots_001", + "kind": "operational_listing.build_tags", + "input": { + "listing": { + "d_tag": "AAAAAAAAAAAAAAAAAAAAAg", + "published_at": 1700000000, + "farm": { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "d_tag": "AAAAAAAAAAAAAAAAAAAAAA" + }, + "product": { + "key": "carrot-nantes", + "title": "Nantes Carrots", + "category": "produce", + "summary": "Fresh bunches harvested in Saanich" + }, + "primary_bin_id": "bunch", + "bins": [ + { + "bin_id": "bunch", + "quantity": { + "amount": "1", + "unit": "each" + }, + "price_per_canonical_unit": { + "amount": { + "amount": "4", + "currency": "CAD" + }, + "quantity": { + "amount": "1", + "unit": "each" + } + } + } + ], + "inventory_available": "24", + "availability": { + "kind": "status", + "amount": { + "status": { + "kind": "active" + } + } + }, + "delivery_method": { + "kind": "pickup" + }, + "location": { + "primary": "Saanich Peninsula", + "city": "Victoria", + "region": "BC", + "country": "CA", + "geohash": "c28hr" + } + } + }, + "expected": { + "tags": [ + [ + "d", + "AAAAAAAAAAAAAAAAAAAAAg" + ], + [ + "p", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + [ + "a", + "30340:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:AAAAAAAAAAAAAAAAAAAAAA" + ], + [ + "key", + "carrot-nantes" + ], + [ + "title", + "Nantes Carrots" + ], + [ + "category", + "produce" + ], + [ + "summary", + "Fresh bunches harvested in Saanich" + ], + [ + "published_at", + "1700000000" + ], + [ + "radroots:primary_bin", + "bunch" + ], + [ + "radroots:bin", + "bunch", + "1", + "each" + ], + [ + "radroots:price", + "bunch", + "4", + "CAD", + "1", + "each" + ], + [ + "price", + "4", + "CAD" + ], + [ + "location", + "Saanich Peninsula", + "Victoria", + "BC", + "CA" + ], + [ + "g", + "c28hr" + ] + ] + } + }, + { + "id": "operational_listing_build_tags_empty_bins_002", + "kind": "operational_listing.build_tags", + "input": { + "listing": { + "d_tag": "AAAAAAAAAAAAAAAAAAAAAw", + "farm": { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "d_tag": "AAAAAAAAAAAAAAAAAAAAAA" + }, + "product": { + "key": "carrot-nantes", + "title": "Nantes Carrots", + "category": "produce" + }, + "primary_bin_id": "bunch", + "bins": [] + } + }, + "expected": { + "error": { + "code": "empty_required_field", + "field": "bins" + } + } + } + ] +} diff --git a/crates/event_codec/tests/fixtures/operational_listing_tags_full.v1.json b/crates/event_codec/tests/fixtures/operational_listing_tags_full.v1.json @@ -0,0 +1,173 @@ +{ + "suite": "events", + "contract_version": "1.0.0", + "vectors": [ + { + "id": "operational_listing_tags_full_victoria_carrots_001", + "kind": "operational_listing_tags_full", + "input": { + "listing": { + "d_tag": "AAAAAAAAAAAAAAAAAAAAAg", + "published_at": 1700000000, + "farm": { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "d_tag": "AAAAAAAAAAAAAAAAAAAAAA" + }, + "product": { + "key": "carrot-nantes", + "title": "Nantes Carrots", + "category": "produce", + "summary": "Fresh bunches harvested in Saanich" + }, + "primary_bin_id": "bunch", + "bins": [ + { + "bin_id": "bunch", + "quantity": { + "amount": "1", + "unit": "each" + }, + "price_per_canonical_unit": { + "amount": { + "amount": "4", + "currency": "CAD" + }, + "quantity": { + "amount": "1", + "unit": "each" + } + } + } + ], + "inventory_available": "24", + "availability": { + "kind": "status", + "amount": { + "status": { + "kind": "active" + } + } + }, + "delivery_method": { + "kind": "pickup" + }, + "location": { + "primary": "Saanich Peninsula", + "city": "Victoria", + "region": "BC", + "country": "CA", + "geohash": "c28hr" + } + } + }, + "expected": { + "tags": [ + [ + "d", + "AAAAAAAAAAAAAAAAAAAAAg" + ], + [ + "p", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + [ + "a", + "30340:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:AAAAAAAAAAAAAAAAAAAAAA" + ], + [ + "key", + "carrot-nantes" + ], + [ + "title", + "Nantes Carrots" + ], + [ + "category", + "produce" + ], + [ + "summary", + "Fresh bunches harvested in Saanich" + ], + [ + "published_at", + "1700000000" + ], + [ + "radroots:primary_bin", + "bunch" + ], + [ + "radroots:bin", + "bunch", + "1", + "each" + ], + [ + "radroots:price", + "bunch", + "4", + "CAD", + "1", + "each" + ], + [ + "price", + "4", + "CAD" + ], + [ + "inventory", + "24" + ], + [ + "status", + "active" + ], + [ + "delivery", + "pickup" + ], + [ + "location", + "Saanich Peninsula", + "Victoria", + "BC", + "CA" + ], + [ + "g", + "c28hr" + ] + ] + } + }, + { + "id": "operational_listing_tags_full_empty_bins_002", + "kind": "operational_listing_tags_full", + "input": { + "listing": { + "d_tag": "AAAAAAAAAAAAAAAAAAAAAw", + "farm": { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "d_tag": "AAAAAAAAAAAAAAAAAAAAAA" + }, + "product": { + "key": "carrot-nantes", + "title": "Nantes Carrots", + "category": "produce" + }, + "primary_bin_id": "bunch", + "bins": [] + } + }, + "expected": { + "error": { + "code": "empty_required_field", + "field": "bins" + } + } + } + ] +} diff --git a/crates/event_codec/tests/operational_listing_conformance.rs b/crates/event_codec/tests/operational_listing_conformance.rs @@ -0,0 +1,419 @@ +#![cfg(feature = "serde_json")] + +use std::{borrow::Cow, collections::BTreeSet, fs, path::Path}; + +use radroots_event::{ + RadrootsEventEnvelope, RadrootsEventEnvelopeParts, contract::validate_event_contract_shape, + operational_listing::RadrootsOperationalListing, +}; +use radroots_event_codec::{ + error::EventEncodeError, + operational_listing::{ + encode::{operational_listing_build_tags, to_wire_parts}, + tags::operational_listing_tags_full, + }, +}; +use serde::{Deserialize, de::DeserializeOwned}; +use serde_json::{Map, Value}; + +const CONTRACT_ID: &str = "radroots.operational_listing.published.v1"; +const WORKSPACE_CONTRACT_MARKER_PATH: &str = "../../contracts/manifest.toml"; +const PACKAGED_BUILD_TAGS: &str = include_str!("fixtures/operational_listing_build_tags.v1.json"); +const WORKSPACE_BUILD_TAGS_PATH: &str = + "../../contracts/conformance/vectors/operational_listing/build_tags.v1.json"; +const PACKAGED_BUILD_DRAFT: &str = include_str!("fixtures/operational_listing_build_draft.v1.json"); +const WORKSPACE_BUILD_DRAFT_PATH: &str = + "../../contracts/conformance/vectors/operational_listing/build_draft.v1.json"; +const PACKAGED_FULL_TAGS: &str = include_str!("fixtures/operational_listing_tags_full.v1.json"); +const WORKSPACE_FULL_TAGS_PATH: &str = + "../../contracts/conformance/vectors/events/operational_listing_tags_full.v1.json"; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Suite<T> { + suite: String, + contract_version: String, + vectors: Vec<Vector<T>>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Vector<T> { + id: String, + kind: String, + input: ListingInput, + expected: T, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ListingInput { + listing: Value, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum TagsExpected { + Success(TagsSuccess), + Failure(EncodeFailure), +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct TagsSuccess { + tags: Vec<Vec<String>>, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum DraftExpected { + Success(DraftSuccess), + Failure(EncodeFailure), +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct DraftSuccess { + wire_parts: WirePartsExpected, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct WirePartsExpected { + kind: u32, + content: String, + tags: Vec<Vec<String>>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct EncodeFailure { + error: EncodeErrorExpected, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct EncodeErrorExpected { + code: String, + field: String, +} + +#[test] +fn checked_in_operational_listing_build_tags_vectors_execute() { + let suite: Suite<TagsExpected> = parse_suite( + PACKAGED_BUILD_TAGS, + WORKSPACE_BUILD_TAGS_PATH, + "operational_listing", + &[ + "operational_listing_build_tags_empty_bins_002", + "operational_listing_build_tags_victoria_carrots_001", + ], + ); + + for vector in &suite.vectors { + let listing = typed_listing(&vector.input.listing, &vector.id); + assert_eq!( + vector.kind, "operational_listing.build_tags", + "{}", + vector.id + ); + match &vector.expected { + TagsExpected::Success(expected) => { + let actual = operational_listing_build_tags(&listing) + .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id)); + assert_eq!(actual, expected.tags, "{}", vector.id); + } + TagsExpected::Failure(expected) => { + let error = operational_listing_build_tags(&listing) + .expect_err("invalid build-tags vector must fail"); + assert_encode_error(&vector.id, &error, &expected.error); + } + } + } + + let mut misspelled = suite.vectors[0].input.listing.clone(); + misspelled["product"]["summmary"] = Value::String("misspelled optional field".to_string()); + assert_eq!( + validate_listing_fixture_keys(&misspelled), + Err("listing.product contains unknown key `summmary`".to_string()) + ); +} + +#[test] +fn checked_in_operational_listing_build_draft_vectors_execute() { + let suite: Suite<DraftExpected> = parse_suite( + PACKAGED_BUILD_DRAFT, + WORKSPACE_BUILD_DRAFT_PATH, + "operational_listing", + &[ + "operational_listing_build_draft_empty_bins_002", + "operational_listing_build_draft_victoria_carrots_001", + ], + ); + + for vector in &suite.vectors { + let listing = typed_listing(&vector.input.listing, &vector.id); + assert_eq!( + vector.kind, "operational_listing.build_draft", + "{}", + vector.id + ); + match &vector.expected { + DraftExpected::Success(expected) => { + let actual = to_wire_parts(&listing) + .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id)); + assert_eq!(actual.kind, expected.wire_parts.kind, "{}", vector.id); + assert_eq!(actual.content, expected.wire_parts.content, "{}", vector.id); + assert_eq!(actual.tags, expected.wire_parts.tags, "{}", vector.id); + } + DraftExpected::Failure(expected) => { + let error = + to_wire_parts(&listing).expect_err("invalid build-draft vector must fail"); + assert_encode_error(&vector.id, &error, &expected.error); + } + } + } +} + +#[test] +fn checked_in_operational_listing_full_event_tag_vectors_execute() { + let suite: Suite<TagsExpected> = parse_suite( + PACKAGED_FULL_TAGS, + WORKSPACE_FULL_TAGS_PATH, + "events", + &[ + "operational_listing_tags_full_empty_bins_002", + "operational_listing_tags_full_victoria_carrots_001", + ], + ); + + for vector in &suite.vectors { + let listing = typed_listing(&vector.input.listing, &vector.id); + assert_eq!( + vector.kind, "operational_listing_tags_full", + "{}", + vector.id + ); + match &vector.expected { + TagsExpected::Success(expected) => { + let actual = operational_listing_tags_full(&listing) + .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id)); + assert_eq!(actual, expected.tags, "{}", vector.id); + + let wire = to_wire_parts(&listing) + .unwrap_or_else(|error| panic!("{} draft failed: {error}", vector.id)); + assert_eq!(wire.tags, actual, "{}", vector.id); + let event = RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { + id: "b".repeat(64), + author: listing.farm.pubkey.clone(), + created_at: listing.published_at.unwrap_or_default(), + kind: wire.kind, + tags: wire.tags, + content: wire.content, + sig: "c".repeat(128), + }) + .unwrap_or_else(|error| panic!("{} envelope failed: {error}", vector.id)); + validate_event_contract_shape(&event, CONTRACT_ID) + .unwrap_or_else(|error| panic!("{} contract failed: {error:?}", vector.id)); + } + TagsExpected::Failure(expected) => { + let error = operational_listing_tags_full(&listing) + .expect_err("invalid full-tags vector must fail"); + assert_encode_error(&vector.id, &error, &expected.error); + } + } + } +} + +fn parse_suite<T: DeserializeOwned>( + packaged: &'static str, + workspace_relative: &str, + expected_suite: &str, + expected_ids: &[&str], +) -> Suite<T> { + let vectors = conformance_vectors(packaged, workspace_relative); + let suite: Suite<T> = serde_json::from_str(&vectors) + .unwrap_or_else(|error| panic!("{workspace_relative} must parse: {error}")); + assert_eq!(suite.suite, expected_suite, "{workspace_relative}"); + assert_eq!(suite.contract_version, "1.0.0", "{workspace_relative}"); + + let actual_ids = suite + .vectors + .iter() + .map(|vector| vector.id.as_str()) + .collect::<BTreeSet<_>>(); + assert_eq!( + actual_ids.len(), + suite.vectors.len(), + "{workspace_relative} contains duplicate vector ids" + ); + let expected_ids = expected_ids.iter().copied().collect::<BTreeSet<_>>(); + assert_eq!( + actual_ids, expected_ids, + "{workspace_relative} inventory drift" + ); + suite +} + +fn conformance_vectors(packaged: &'static str, workspace_relative: &str) -> Cow<'static, str> { + let workspace_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(workspace_relative); + match fs::read_to_string(&workspace_path) { + Ok(canonical) => { + assert_eq!( + canonical, + packaged, + "packaged 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) + } + Err(error) => panic!("failed to read {}: {error}", workspace_path.display()), + } +} + +fn assert_encode_error(id: &str, actual: &EventEncodeError, expected: &EncodeErrorExpected) { + assert_eq!(actual.code(), expected.code, "{id}"); + match actual { + EventEncodeError::EmptyRequiredField(field) => { + assert_eq!(*field, expected.field, "{id}"); + } + other => panic!("{id} returned unexpected encode error {other:?}"), + } +} + +fn typed_listing(raw: &Value, id: &str) -> RadrootsOperationalListing { + validate_listing_fixture_keys(raw) + .unwrap_or_else(|error| panic!("{id} fixture schema failed: {error}")); + serde_json::from_value(raw.clone()) + .unwrap_or_else(|error| panic!("{id} listing must deserialize: {error}")) +} + +fn validate_listing_fixture_keys(raw: &Value) -> Result<(), String> { + let listing = fixture_object(raw, "listing")?; + assert_allowed_keys( + listing, + &[ + "d_tag", + "published_at", + "farm", + "product", + "primary_bin_id", + "bins", + "inventory_available", + "availability", + "delivery_method", + "location", + ], + "listing", + )?; + + let farm = required_fixture_object(listing, "farm", "listing")?; + assert_allowed_keys(farm, &["pubkey", "d_tag"], "listing.farm")?; + + let product = required_fixture_object(listing, "product", "listing")?; + assert_allowed_keys( + product, + &["key", "title", "category", "summary"], + "listing.product", + )?; + + let bins = listing + .get("bins") + .and_then(Value::as_array) + .ok_or_else(|| "listing.bins must be an array".to_string())?; + for (index, raw_bin) in bins.iter().enumerate() { + let bin_path = format!("listing.bins[{index}]"); + let bin = fixture_object(raw_bin, &bin_path)?; + assert_allowed_keys( + bin, + &["bin_id", "quantity", "price_per_canonical_unit"], + &bin_path, + )?; + + let quantity = required_fixture_object(bin, "quantity", &bin_path)?; + assert_allowed_keys( + quantity, + &["amount", "unit"], + &format!("{bin_path}.quantity"), + )?; + + let price = required_fixture_object(bin, "price_per_canonical_unit", &bin_path)?; + let price_path = format!("{bin_path}.price_per_canonical_unit"); + assert_allowed_keys(price, &["amount", "quantity"], &price_path)?; + let money = required_fixture_object(price, "amount", &price_path)?; + assert_allowed_keys( + money, + &["amount", "currency"], + &format!("{price_path}.amount"), + )?; + let price_quantity = required_fixture_object(price, "quantity", &price_path)?; + assert_allowed_keys( + price_quantity, + &["amount", "unit"], + &format!("{price_path}.quantity"), + )?; + } + + if let Some(raw_availability) = listing.get("availability") { + let availability = fixture_object(raw_availability, "listing.availability")?; + assert_allowed_keys(availability, &["kind", "amount"], "listing.availability")?; + let amount = required_fixture_object(availability, "amount", "listing.availability")?; + assert_allowed_keys(amount, &["status"], "listing.availability.amount")?; + let status = required_fixture_object(amount, "status", "listing.availability.amount")?; + assert_allowed_keys(status, &["kind"], "listing.availability.amount.status")?; + } + + if let Some(raw_delivery) = listing.get("delivery_method") { + let delivery = fixture_object(raw_delivery, "listing.delivery_method")?; + assert_allowed_keys(delivery, &["kind"], "listing.delivery_method")?; + } + + if let Some(raw_location) = listing.get("location") { + let location = fixture_object(raw_location, "listing.location")?; + assert_allowed_keys( + location, + &["primary", "city", "region", "country", "geohash"], + "listing.location", + )?; + } + + Ok(()) +} + +fn fixture_object<'a>(raw: &'a Value, path: &str) -> Result<&'a Map<String, Value>, String> { + raw.as_object() + .ok_or_else(|| format!("{path} must be an object")) +} + +fn required_fixture_object<'a>( + parent: &'a Map<String, Value>, + key: &str, + parent_path: &str, +) -> Result<&'a Map<String, Value>, String> { + let path = format!("{parent_path}.{key}"); + parent + .get(key) + .ok_or_else(|| format!("{path} is required")) + .and_then(|raw| fixture_object(raw, &path)) +} + +fn assert_allowed_keys( + object: &Map<String, Value>, + allowed: &[&str], + path: &str, +) -> Result<(), String> { + for key in object.keys() { + if !allowed.contains(&key.as_str()) { + return Err(format!("{path} contains unknown key `{key}`")); + } + } + Ok(()) +} diff --git a/crates/trade/tests/fixtures/parse_classified_listing_address.v1.json b/crates/trade/tests/fixtures/parse_classified_listing_address.v1.json @@ -0,0 +1,101 @@ +{ + "suite": "trade", + "contract_version": "1.0.0", + "vectors": [ + { + "id": "trade_parse_classified_listing_address_canonical_001", + "kind": "trade.parse_classified_listing_address.valid", + "input": { + "listing_addr": "30402:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:AAAAAAAAAAAAAAAAAAAAAg" + }, + "expected": { + "address": "30402:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:AAAAAAAAAAAAAAAAAAAAAg", + "kind": 30402, + "seller_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "listing_id": "AAAAAAAAAAAAAAAAAAAAAg" + } + }, + { + "id": "trade_parse_classified_listing_address_normalized_002", + "kind": "trade.parse_classified_listing_address.valid", + "input": { + "listing_addr": "+030402:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:AAAAAAAAAAAAAAAAAAAAAg" + }, + "expected": { + "address": "30402:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:AAAAAAAAAAAAAAAAAAAAAg", + "kind": 30402, + "seller_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "listing_id": "AAAAAAAAAAAAAAAAAAAAAg" + } + }, + { + "id": "trade_parse_classified_listing_address_wrong_kind_003", + "kind": "trade.parse_classified_listing_address.invalid", + "input": { + "listing_addr": "30403:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:AAAAAAAAAAAAAAAAAAAAAg" + }, + "expected": { + "error": { + "kind": "unexpected_kind", + "expected": 30402, + "actual": 30403 + }, + "message": "identifier kind 30403 does not match required kind 30402" + } + }, + { + "id": "trade_parse_classified_listing_address_malformed_format_004", + "kind": "trade.parse_classified_listing_address.invalid", + "input": { + "listing_addr": "30402" + }, + "expected": { + "error": { + "kind": "invalid_format" + }, + "message": "identifier has invalid format" + } + }, + { + "id": "trade_parse_classified_listing_address_short_pubkey_005", + "kind": "trade.parse_classified_listing_address.invalid", + "input": { + "listing_addr": "30402:not_hex:AAAAAAAAAAAAAAAAAAAAAg" + }, + "expected": { + "error": { + "kind": "invalid_length", + "expected": 64, + "actual": 7 + }, + "message": "identifier length 7 does not match required length 64" + } + }, + { + "id": "trade_parse_classified_listing_address_non_hex_pubkey_006", + "kind": "trade.parse_classified_listing_address.invalid", + "input": { + "listing_addr": "30402:gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg:AAAAAAAAAAAAAAAAAAAAAg" + }, + "expected": { + "error": { + "kind": "invalid_character" + }, + "message": "identifier contains an invalid character" + } + }, + { + "id": "trade_parse_classified_listing_address_invalid_d_tag_007", + "kind": "trade.parse_classified_listing_address.invalid", + "input": { + "listing_addr": "30402:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:bad d" + }, + "expected": { + "error": { + "kind": "invalid_character" + }, + "message": "identifier contains an invalid character" + } + } + ] +} diff --git a/crates/trade/tests/fixtures/validate_operational_listing_event.v1.json b/crates/trade/tests/fixtures/validate_operational_listing_event.v1.json @@ -0,0 +1,222 @@ +{ + "suite": "trade_validation", + "contract_version": "1.0.0", + "vectors": [ + { + "id": "trade_validation_validate_operational_listing_event_valid_001", + "kind": "trade_validation.validate_operational_listing_event.valid", + "input": { + "event": { + "id": "6739ed38175521d1b8a64592ec3407332ee24449e1e7353fd8f721c0995b9d8f", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000000, + "kind": 30402, + "tags": [ + ["d", "AAAAAAAAAAAAAAAAAAAAAg"], + ["p", "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"], + ["a", "30340:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "carrot-nantes"], + ["title", "Nantes Carrots"], + ["category", "produce"], + ["summary", "Fresh bunches harvested in Saanich"], + ["published_at", "1700000000"], + ["radroots:primary_bin", "bunch"], + ["radroots:bin", "bunch", "1", "each"], + ["radroots:price", "bunch", "4", "CAD", "1", "each"], + ["price", "4", "CAD"], + ["inventory", "24"], + ["status", "active"], + ["delivery", "pickup"], + ["location", "Saanich Peninsula", "Victoria", "BC", "CA"], + ["g", "c28hr"] + ], + "content": "# Nantes Carrots\n\nFresh bunches harvested in Saanich", + "sig": "ef3413c4ac4be3f748fcb51b3e5b9b03c590f5f814dbd6ab3f2f2c26dbc87eb1347cefbe97abacce60f0c4530848695e766d3a950350c72089813b3318a3f944" + } + }, + "expected": { + "projection": { + "listing_id": "AAAAAAAAAAAAAAAAAAAAAg", + "listing_addr": "30402:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:AAAAAAAAAAAAAAAAAAAAAg", + "seller_pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "title": "Nantes Carrots", + "description": "Fresh bunches harvested in Saanich", + "product_type": "produce", + "primary_bin_id": "bunch", + "bin_quantity": { + "amount": "1", + "unit": "each" + }, + "unit": "each", + "unit_price": { + "amount": "4", + "currency": "CAD" + }, + "inventory_available": "24", + "availability": { + "kind": "status", + "amount": { + "status": { + "kind": "active" + } + } + }, + "location": { + "primary": "Saanich Peninsula", + "city": "Victoria", + "region": "BC", + "country": "CA", + "geohash": "c28hr" + }, + "delivery_method": { + "kind": "pickup" + }, + "listing": { + "d_tag": "AAAAAAAAAAAAAAAAAAAAAg", + "published_at": 1700000000, + "farm": { + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "AAAAAAAAAAAAAAAAAAAAAA" + }, + "product": { + "key": "carrot-nantes", + "title": "Nantes Carrots", + "category": "produce", + "summary": "Fresh bunches harvested in Saanich", + "process": null, + "lot": null, + "location": null, + "profile": null, + "year": null + }, + "primary_bin_id": "bunch", + "bins": [ + { + "bin_id": "bunch", + "quantity": { + "amount": "1", + "unit": "each" + }, + "price_per_canonical_unit": { + "amount": { + "amount": "4", + "currency": "CAD" + }, + "quantity": { + "amount": "1", + "unit": "each" + } + }, + "display_amount": null, + "display_unit": null, + "display_label": null, + "display_price": null, + "display_price_unit": null + } + ], + "resource_area": null, + "plot": null, + "discounts": null, + "inventory_available": "24", + "availability": { + "kind": "status", + "amount": { + "status": { + "kind": "active" + } + } + }, + "delivery_method": { + "kind": "pickup" + }, + "location": { + "primary": "Saanich Peninsula", + "city": "Victoria", + "region": "BC", + "country": "CA", + "geohash": "c28hr" + }, + "images": null + } + } + } + }, + { + "id": "trade_validation_validate_operational_listing_event_invalid_seller_002", + "kind": "trade_validation.validate_operational_listing_event.invalid", + "input": { + "event": { + "id": "294177fd3f698947e8f1be9f1f3429c87d87d17a643b1c3666674de7bbe6eb20", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000000, + "kind": 30402, + "tags": [ + ["d", "AAAAAAAAAAAAAAAAAAAAAg"], + ["p", "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"], + ["a", "30340:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "carrot-nantes"], + ["title", "Nantes Carrots"], + ["category", "produce"], + ["summary", "Fresh bunches harvested in Saanich"], + ["published_at", "1700000000"], + ["radroots:primary_bin", "bunch"], + ["radroots:bin", "bunch", "1", "each"], + ["radroots:price", "bunch", "4", "CAD", "1", "each"], + ["price", "4", "CAD"], + ["inventory", "24"], + ["status", "active"], + ["delivery", "pickup"], + ["location", "Saanich Peninsula", "Victoria", "BC", "CA"], + ["g", "c28hr"] + ], + "content": "# Nantes Carrots\n\nFresh bunches harvested in Saanich", + "sig": "0b12724932817061d949d1d4543beaa90b27b08f09f63422871dda01ee692b26c4ff9ef66b31ea4500b76d1c8e45c2126c96b7dc17b8f242b29b97a5a7294b73" + } + }, + "expected": { + "error": { + "kind": "invalid_seller" + }, + "message": "listing author does not match farm pubkey" + } + }, + { + "id": "trade_validation_validate_operational_listing_event_missing_inventory_003", + "kind": "trade_validation.validate_operational_listing_event.invalid", + "input": { + "event": { + "id": "b2d934d5b9a089a500c7c2a76fb1486b4abf2dd4b9334e3dc00114884818e77a", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000000, + "kind": 30402, + "tags": [ + ["d", "AAAAAAAAAAAAAAAAAAAAAg"], + ["p", "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"], + ["a", "30340:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "carrot-nantes"], + ["title", "Nantes Carrots"], + ["category", "produce"], + ["summary", "Fresh bunches harvested in Saanich"], + ["published_at", "1700000000"], + ["radroots:primary_bin", "bunch"], + ["radroots:bin", "bunch", "1", "each"], + ["radroots:price", "bunch", "4", "CAD", "1", "each"], + ["price", "4", "CAD"], + ["status", "active"], + ["delivery", "pickup"], + ["location", "Saanich Peninsula", "Victoria", "BC", "CA"], + ["g", "c28hr"] + ], + "content": "# Nantes Carrots\n\nFresh bunches harvested in Saanich", + "sig": "3504a764b97f8fd2915281e8c110247979da5f62743967227711a08c08de5a87ad79500def9da29730e12175dbe656f75b75fe2dae4a4e25c7f989aecd3520ef" + } + }, + "expected": { + "error": { + "kind": "missing_inventory" + }, + "message": "missing listing inventory" + } + } + ] +} diff --git a/crates/trade/tests/operational_listing_conformance.rs b/crates/trade/tests/operational_listing_conformance.rs @@ -0,0 +1,316 @@ +#![cfg(all(feature = "std", feature = "serde", feature = "serde_json"))] + +use nostr::secp256k1::Message; +use nostr::{Event as NostrEvent, JsonUtil, Keys, SECP256K1}; +use radroots_event::{RadrootsNip01EventWire, ids::RadrootsIdParseError}; +use radroots_nostr::prelude::radroots_event_from_nostr; +use radroots_trade::operational_listing::{ + parse_classified_listing_address, validation::validate_operational_listing_event, +}; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::{borrow::Cow, collections::BTreeSet, fs, path::Path}; + +const SIGNING_SECRET_KEY: &str = "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; +const PACKAGED_ADDRESS_VECTORS: &str = + include_str!("fixtures/parse_classified_listing_address.v1.json"); +const PACKAGED_VALIDATION_VECTORS: &str = + include_str!("fixtures/validate_operational_listing_event.v1.json"); +const WORKSPACE_ADDRESS_VECTOR_PATH: &str = + "../../contracts/conformance/vectors/trade/parse_classified_listing_address.v1.json"; +const WORKSPACE_VALIDATION_VECTOR_PATH: &str = "../../contracts/conformance/vectors/trade_validation/validate_operational_listing_event.v1.json"; +const WORKSPACE_CONTRACT_MARKER_PATH: &str = "../../contracts/manifest.toml"; + +const ADDRESS_VECTOR_IDS: [&str; 7] = [ + "trade_parse_classified_listing_address_canonical_001", + "trade_parse_classified_listing_address_normalized_002", + "trade_parse_classified_listing_address_wrong_kind_003", + "trade_parse_classified_listing_address_malformed_format_004", + "trade_parse_classified_listing_address_short_pubkey_005", + "trade_parse_classified_listing_address_non_hex_pubkey_006", + "trade_parse_classified_listing_address_invalid_d_tag_007", +]; + +const VALIDATION_VECTOR_IDS: [&str; 3] = [ + "trade_validation_validate_operational_listing_event_valid_001", + "trade_validation_validate_operational_listing_event_invalid_seller_002", + "trade_validation_validate_operational_listing_event_missing_inventory_003", +]; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Suite { + suite: String, + contract_version: String, + vectors: Vec<Vector>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Vector { + id: String, + kind: String, + input: Value, + expected: Value, +} + +#[test] +fn classified_listing_address_vectors_execute_against_public_api() { + let raw = conformance_vectors( + PACKAGED_ADDRESS_VECTORS, + WORKSPACE_ADDRESS_VECTOR_PATH, + "Classified Listing address", + ); + let suite: Suite = serde_json::from_str(&raw).expect("address vectors must parse"); + assert_suite(&suite, "trade", &ADDRESS_VECTOR_IDS); + + for vector in &suite.vectors { + match vector.kind.as_str() { + "trade.parse_classified_listing_address.valid" => address_valid(vector), + "trade.parse_classified_listing_address.invalid" => address_invalid(vector), + kind => panic!("{} uses unsupported vector kind {kind}", vector.id), + } + } +} + +#[test] +fn operational_listing_validation_vectors_execute_verified_nip01_events() { + let raw = conformance_vectors( + PACKAGED_VALIDATION_VECTORS, + WORKSPACE_VALIDATION_VECTOR_PATH, + "Operational Listing validation", + ); + let suite: Suite = serde_json::from_str(&raw).expect("validation vectors must parse"); + assert_suite(&suite, "trade_validation", &VALIDATION_VECTOR_IDS); + let keys = Keys::parse(SIGNING_SECRET_KEY).expect("fixed signing key must parse"); + + for vector in &suite.vectors { + let event = verified_event(vector, &keys); + match vector.kind.as_str() { + "trade_validation.validate_operational_listing_event.valid" => { + validation_valid(vector, &event) + } + "trade_validation.validate_operational_listing_event.invalid" => { + validation_invalid(vector, &event) + } + kind => panic!("{} uses unsupported vector kind {kind}", vector.id), + } + } +} + +fn conformance_vectors( + packaged: &'static str, + workspace_relative: &str, + label: &str, +) -> Cow<'static, str> { + let workspace_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(workspace_relative); + match fs::read_to_string(&workspace_path) { + Ok(canonical) => { + assert_eq!( + canonical, + packaged, + "packaged {label} 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) + } + Err(error) => panic!("failed to read {}: {error}", workspace_path.display()), + } +} + +fn assert_suite(suite: &Suite, expected_suite: &str, expected_ids: &[&str]) { + assert_eq!(suite.suite, expected_suite); + assert_eq!(suite.contract_version, "1.0.0"); + assert_eq!(suite.vectors.len(), expected_ids.len()); + + let actual: BTreeSet<&str> = suite + .vectors + .iter() + .map(|vector| vector.id.as_str()) + .collect(); + let expected: BTreeSet<&str> = expected_ids.iter().copied().collect(); + assert_eq!(actual, expected, "conformance vector inventory drift"); +} + +fn address_valid(vector: &Vector) { + assert_object_keys(&vector.input, &["listing_addr"], "input", &vector.id); + assert_object_keys( + &vector.expected, + &["address", "kind", "seller_pubkey", "listing_id"], + "expected", + &vector.id, + ); + let parsed = parse_classified_listing_address(input_str(vector, "listing_addr")) + .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id)); + let actual = json!({ + "address": parsed.address.as_str(), + "kind": parsed.kind, + "seller_pubkey": parsed.seller_pubkey.as_str(), + "listing_id": parsed.listing_id.as_str(), + }); + assert_eq!(actual, vector.expected, "{}", vector.id); +} + +fn address_invalid(vector: &Vector) { + assert_object_keys(&vector.input, &["listing_addr"], "input", &vector.id); + assert_object_keys( + &vector.expected, + &["error", "message"], + "expected", + &vector.id, + ); + let error = parse_classified_listing_address(input_str(vector, "listing_addr")) + .expect_err("invalid Classified Listing address vector must fail"); + let actual = json!({ + "error": address_error_value(&error), + "message": error.to_string(), + }); + assert_eq!(actual, vector.expected, "{}", vector.id); +} + +fn address_error_value(error: &RadrootsIdParseError) -> Value { + match error { + RadrootsIdParseError::Empty => json!({ "kind": "empty" }), + RadrootsIdParseError::InvalidFormat => json!({ "kind": "invalid_format" }), + RadrootsIdParseError::InvalidLength { expected, actual } => json!({ + "kind": "invalid_length", + "expected": expected, + "actual": actual, + }), + RadrootsIdParseError::InvalidCharacter => json!({ "kind": "invalid_character" }), + RadrootsIdParseError::UnexpectedKind { expected, actual } => json!({ + "kind": "unexpected_kind", + "expected": expected, + "actual": actual, + }), + RadrootsIdParseError::TooLong { max, actual } => json!({ + "kind": "too_long", + "max": max, + "actual": actual, + }), + } +} + +fn verified_event(vector: &Vector, keys: &Keys) -> radroots_event::RadrootsEventEnvelope { + assert_object_keys(&vector.input, &["event"], "input", &vector.id); + let event_value = vector + .input + .get("event") + .unwrap_or_else(|| panic!("{} is missing input.event", vector.id)); + assert_object_keys( + event_value, + &[ + "id", + "pubkey", + "created_at", + "kind", + "tags", + "content", + "sig", + ], + "input.event", + &vector.id, + ); + let raw = serde_json::to_string(event_value).expect("event vector must serialize"); + let event = NostrEvent::from_json(&raw) + .unwrap_or_else(|error| panic!("{} is not a NIP-01 event: {error}", vector.id)); + let expected_id = value_str(event_value, "id", &vector.id); + let expected_signature = value_str(event_value, "sig", &vector.id); + + assert_eq!(event.id.to_string(), expected_id, "{}", vector.id); + assert_eq!(event.sig.to_string(), expected_signature, "{}", vector.id); + assert_eq!( + event.pubkey, + keys.public_key(), + "{} signer drift", + vector.id + ); + let message = Message::from_digest(event.id.to_bytes()); + let deterministic_signature = + SECP256K1.sign_schnorr_no_aux_rand(&message, keys.key_pair(SECP256K1)); + assert_eq!( + event.sig, deterministic_signature, + "{} deterministic signature drift", + vector.id + ); + event + .verify() + .unwrap_or_else(|error| panic!("{} failed NIP-01 verification: {error}", vector.id)); + + let wire = RadrootsNip01EventWire::parse_json(&raw) + .unwrap_or_else(|error| panic!("{} failed Radroots wire parsing: {error}", vector.id)); + assert!( + wire.extra.is_empty(), + "{} has extra NIP-01 fields", + vector.id + ); + assert_eq!(wire.id, expected_id, "{}", vector.id); + assert_eq!(wire.sig, expected_signature, "{}", vector.id); + wire.verify_id() + .unwrap_or_else(|error| panic!("{} failed Radroots ID verification: {error}", vector.id)); + let envelope = wire + .into_envelope() + .unwrap_or_else(|error| panic!("{} failed envelope conversion: {error}", vector.id)); + let adapted = radroots_event_from_nostr(&event) + .unwrap_or_else(|error| panic!("{} failed Nostr adapter conversion: {error}", vector.id)); + assert_eq!(envelope, adapted, "{} conversion drift", vector.id); + envelope +} + +fn validation_valid(vector: &Vector, event: &radroots_event::RadrootsEventEnvelope) { + assert_object_keys(&vector.expected, &["projection"], "expected", &vector.id); + let projection = validate_operational_listing_event(event) + .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id)); + assert_eq!( + projection.listing.farm.pubkey, + event.author_str(), + "{} decoded farm author drift", + vector.id + ); + let actual = serde_json::to_value(projection).expect("projection must serialize"); + assert_eq!(actual, vector.expected["projection"], "{}", vector.id); +} + +fn validation_invalid(vector: &Vector, event: &radroots_event::RadrootsEventEnvelope) { + assert_object_keys( + &vector.expected, + &["error", "message"], + "expected", + &vector.id, + ); + let error = validate_operational_listing_event(event) + .expect_err("invalid Operational Listing validation vector must fail"); + let actual = json!({ + "error": serde_json::to_value(&error).expect("validation error must serialize"), + "message": error.to_string(), + }); + assert_eq!(actual, vector.expected, "{}", vector.id); +} + +fn input_str<'a>(vector: &'a Vector, key: &str) -> &'a str { + value_str(&vector.input, key, &vector.id) +} + +fn value_str<'a>(value: &'a Value, key: &str, vector_id: &str) -> &'a str { + value + .get(key) + .and_then(Value::as_str) + .unwrap_or_else(|| panic!("{vector_id} is missing string field {key}")) +} + +fn assert_object_keys(value: &Value, expected: &[&str], path: &str, vector_id: &str) { + let object = value + .as_object() + .unwrap_or_else(|| panic!("{vector_id} {path} must be an object")); + let actual = object.keys().map(String::as_str).collect::<BTreeSet<_>>(); + let expected = expected.iter().copied().collect::<BTreeSet<_>>(); + assert_eq!(actual, expected, "{vector_id} {path} key drift"); +} diff --git a/tools/xtask/src/contract.rs b/tools/xtask/src/contract.rs @@ -30,7 +30,7 @@ const REPLICA_CONTRACT_NAME: &str = "radroots_replica_contract"; const REPLICA_TRANSFER_CONSTANT: &str = "RADROOTS_REPLICA_TRANSFER_VERSION"; const REPLICA_TRANSFER_VERSION: u32 = 2; const VENDORED_WORKSPACE_MEMBER_RELATIVE: &str = "crates/libsqlite3_sys_3_53_3"; -const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 9] = [ +const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 14] = [ ( "contracts/conformance/vectors/blossom/bud11_claims.v1.json", "crates/blossom/tests/fixtures/bud11_claims.v1.json", @@ -52,6 +52,18 @@ const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 9] = [ "crates/event_codec/tests/fixtures/calendar_radroots_profile.v1.json", ), ( + "contracts/conformance/vectors/events/operational_listing_tags_full.v1.json", + "crates/event_codec/tests/fixtures/operational_listing_tags_full.v1.json", + ), + ( + "contracts/conformance/vectors/operational_listing/build_draft.v1.json", + "crates/event_codec/tests/fixtures/operational_listing_build_draft.v1.json", + ), + ( + "contracts/conformance/vectors/operational_listing/build_tags.v1.json", + "crates/event_codec/tests/fixtures/operational_listing_build_tags.v1.json", + ), + ( "contracts/conformance/vectors/operational_listing/parse_event.v1.json", "crates/event_codec/tests/fixtures/operational_listing_parse_event.v1.json", ), @@ -67,6 +79,14 @@ const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 9] = [ "contracts/conformance/vectors/post/verified_profiles.v1.json", "crates/event_codec/tests/fixtures/post_verified_profiles.v1.json", ), + ( + "contracts/conformance/vectors/trade/parse_classified_listing_address.v1.json", + "crates/trade/tests/fixtures/parse_classified_listing_address.v1.json", + ), + ( + "contracts/conformance/vectors/trade_validation/validate_operational_listing_event.v1.json", + "crates/trade/tests/fixtures/validate_operational_listing_event.v1.json", + ), ]; const KNOWLEDGE_MVP_SUPPORT_CONTRACT_IDS: [&str; 8] = [ "radroots.wiki.article.v1", @@ -6579,14 +6599,7 @@ vector = "contracts/conformance/vectors/operational_listing/build_draft.v1.json" .join("metadata.v1.json"), SYNTHETIC_CONFORMANCE_VECTOR, ); - write_file( - &root - .join("contracts") - .join("conformance") - .join("vectors") - .join("operational_listing") - .join("build_draft.v1.json"), - r#"{ + let operational_listing_vector = r#"{ "suite": "operational_listing", "contract_version": "1.0.0", "vectors": [ @@ -6598,7 +6611,24 @@ vector = "contracts/conformance/vectors/operational_listing/build_draft.v1.json" } ] } -"#, +"#; + write_file( + &root + .join("contracts") + .join("conformance") + .join("vectors") + .join("operational_listing") + .join("build_draft.v1.json"), + operational_listing_vector, + ); + write_file( + &root + .join("crates") + .join("event_codec") + .join("tests") + .join("fixtures") + .join("operational_listing_build_draft.v1.json"), + operational_listing_vector, ); }