diff --git a/python/capture_sdk/__init__.py b/python/capture_sdk/__init__.py index efee538..1227f52 100644 --- a/python/capture_sdk/__init__.py +++ b/python/capture_sdk/__init__.py @@ -31,6 +31,7 @@ CaptureOptions, Commit, FileInput, + License, NftRecord, NftSearchResult, RegisterOptions, @@ -39,7 +40,7 @@ UpdateOptions, ) -__version__ = "0.1.0" +__version__ = "0.2.0" __all__ = [ # Main client @@ -53,6 +54,7 @@ "Asset", "Commit", "AssetTree", + "License", "AssetSearchOptions", "AssetSearchResult", "SimilarMatch", diff --git a/python/capture_sdk/client.py b/python/capture_sdk/client.py index 89434e3..480455d 100644 --- a/python/capture_sdk/client.py +++ b/python/capture_sdk/client.py @@ -20,6 +20,7 @@ CaptureOptions, Commit, FileInput, + License, NftRecord, NftSearchResult, RegisterOptions, @@ -544,10 +545,28 @@ def get_asset_tree(self, nid: str) -> AssetTree: "headline", "license", "mimeType", + "nftRecord", + "usedBy", + "integrityCid", + "digitalSourceType", + "miningPreference", + "generatedBy", } extra = {k: v for k, v in merged.items() if k not in known_fields} + # Parse license field - can be object or string + license_data = merged.get("license") + license_obj = None + if isinstance(license_data, dict): + license_obj = License( + name=license_data.get("name"), + document=license_data.get("document"), + ) + elif isinstance(license_data, str): + # Backwards compatibility: treat string as license name + license_obj = License(name=license_data) + return AssetTree( asset_cid=merged.get("assetCid"), asset_sha256=merged.get("assetSha256"), @@ -557,8 +576,14 @@ def get_asset_tree(self, nid: str) -> AssetTree: location_created=merged.get("locationCreated"), caption=merged.get("caption"), headline=merged.get("headline"), - license=merged.get("license"), + license=license_obj, mime_type=merged.get("mimeType"), + nft_record=merged.get("nftRecord"), + used_by=merged.get("usedBy"), + integrity_cid=merged.get("integrityCid"), + digital_source_type=merged.get("digitalSourceType"), + mining_preference=merged.get("miningPreference"), + generated_by=merged.get("generatedBy"), extra=extra, ) diff --git a/python/capture_sdk/types.py b/python/capture_sdk/types.py index ad8d333..371d062 100644 --- a/python/capture_sdk/types.py +++ b/python/capture_sdk/types.py @@ -119,15 +119,31 @@ class Commit: """Description of the action.""" +@dataclass +class License: + """License information for an asset.""" + + name: str | None = None + """License name (e.g., 'CC BY 4.0').""" + + document: str | None = None + """URL to the license document.""" + + @dataclass class AssetTree: - """Merged asset tree containing full provenance data.""" + """ + Merged asset tree containing full provenance data. + + Follows the Numbers Protocol AssetTree specification. + See: https://docs.numbersprotocol.io/introduction/numbers-protocol/defining-web3-assets/assettree + """ asset_cid: str | None = None - """Asset content identifier.""" + """Asset content identifier (IPFS CID).""" asset_sha256: str | None = None - """SHA-256 hash of the asset.""" + """SHA-256 hash of the asset file.""" creator_name: str | None = None """Creator's name.""" @@ -136,22 +152,40 @@ class AssetTree: """Creator's wallet address.""" created_at: int | None = None - """Creation timestamp.""" + """Unix timestamp when asset was created.""" location_created: str | None = None """Location where asset was created.""" caption: str | None = None - """Asset description.""" + """Asset description/abstract.""" headline: str | None = None """Asset title.""" - license: str | None = None + license: License | None = None """License information.""" mime_type: str | None = None - """MIME type.""" + """MIME type (encodingFormat).""" + + nft_record: str | None = None + """NFT record CID (if asset has been minted as NFT).""" + + used_by: str | None = None + """URL of website that uses the asset.""" + + integrity_cid: str | None = None + """IPFS CID of the integrity proof.""" + + digital_source_type: str | None = None + """Digital source type (e.g., digitalCapture, trainedAlgorithmicMedia).""" + + mining_preference: str | None = None + """Mining/indexing preference.""" + + generated_by: str | None = None + """AI/algorithm information for generated content.""" extra: dict[str, Any] = field(default_factory=dict) """Additional fields from commits.""" diff --git a/python/pyproject.toml b/python/pyproject.toml index 6136790..1d992e2 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "capture-sdk" -version = "0.1.0" +version = "0.2.0" description = "Python SDK for Numbers Protocol Capture API" readme = "README.md" license = "MIT" diff --git a/python/tests/test_asset_search.py b/python/tests/test_asset_search.py new file mode 100644 index 0000000..188e463 --- /dev/null +++ b/python/tests/test_asset_search.py @@ -0,0 +1,239 @@ +""" +Unit tests for asset search (Verify Engine) functionality. + +These tests verify that the SDK correctly parses asset search responses +with precise matches and similar matches. +""" + +import pytest +import respx +from httpx import Response + +from capture_sdk import Capture +from capture_sdk.types import AssetSearchResult, SimilarMatch + +# Test asset NID +TEST_NID = "bafybeif3mhxhkhfwuszl2lybtai3hz3q6naqpfisd4q55mcc7opkmiv5ei" + +# Mock API URL +ASSET_SEARCH_API_URL = "https://us-central1-numbers-protocol-api.cloudfunctions.net/asset-search" + + +@pytest.fixture +def capture_client(): + """Create a Capture client with test token.""" + return Capture(token="test-token") + + +@pytest.fixture +def mock_search_response_exact_match(): + """Mock response with exact match.""" + return { + "precise_match": TEST_NID, + "input_file_mime_type": "image/png", + "similar_matches": [ + {"nid": "bafybei111", "distance": 0.05}, + {"nid": "bafybei222", "distance": 0.12}, + {"nid": "bafybei333", "distance": 0.18}, + ], + "order_id": "order_123", + } + + +@pytest.fixture +def mock_search_response_similar_only(): + """Mock response with similar matches only (no exact match).""" + return { + "precise_match": "", + "input_file_mime_type": "image/jpeg", + "similar_matches": [ + {"nid": TEST_NID, "distance": 0.02}, + {"nid": "bafybei444", "distance": 0.15}, + {"nid": "bafybei555", "distance": 0.22}, + ], + "order_id": "order_456", + } + + +class TestAssetSearchParsing: + """Tests for asset search response parsing.""" + + @respx.mock + def test_search_asset_returns_precise_match( + self, + capture_client, + mock_search_response_exact_match, + ): + """Verify precise_match is correctly parsed from API response.""" + respx.post(ASSET_SEARCH_API_URL).mock( + return_value=Response(200, json=mock_search_response_exact_match) + ) + + result = capture_client.search_asset(nid=TEST_NID) + + assert result.precise_match == TEST_NID + + @respx.mock + def test_search_asset_returns_similar_matches( + self, + capture_client, + mock_search_response_exact_match, + ): + """Verify similar_matches is correctly parsed from API response.""" + respx.post(ASSET_SEARCH_API_URL).mock( + return_value=Response(200, json=mock_search_response_exact_match) + ) + + result = capture_client.search_asset(nid=TEST_NID) + + assert len(result.similar_matches) == 3 + assert result.similar_matches[0].nid == "bafybei111" + assert result.similar_matches[0].distance == 0.05 + + @respx.mock + def test_search_asset_finds_nid_in_similar_matches( + self, + capture_client, + mock_search_response_similar_only, + ): + """Verify search finds the expected NID in similar matches.""" + respx.post(ASSET_SEARCH_API_URL).mock( + return_value=Response(200, json=mock_search_response_similar_only) + ) + + result = capture_client.search_asset(nid=TEST_NID) + + # Find the expected NID in similar matches + matching_nids = [m.nid for m in result.similar_matches] + assert TEST_NID in matching_nids + + @respx.mock + def test_search_asset_returns_order_id( + self, + capture_client, + mock_search_response_exact_match, + ): + """Verify order_id is correctly parsed from API response.""" + respx.post(ASSET_SEARCH_API_URL).mock( + return_value=Response(200, json=mock_search_response_exact_match) + ) + + result = capture_client.search_asset(nid=TEST_NID) + + assert result.order_id == "order_123" + + @respx.mock + def test_search_asset_returns_mime_type( + self, + capture_client, + mock_search_response_exact_match, + ): + """Verify input_file_mime_type is correctly parsed from API response.""" + respx.post(ASSET_SEARCH_API_URL).mock( + return_value=Response(200, json=mock_search_response_exact_match) + ) + + result = capture_client.search_asset(nid=TEST_NID) + + assert result.input_file_mime_type == "image/png" + + @respx.mock + def test_search_asset_includes_other_similar_assets( + self, + capture_client, + mock_search_response_similar_only, + ): + """Verify search includes other similar assets besides the exact match.""" + respx.post(ASSET_SEARCH_API_URL).mock( + return_value=Response(200, json=mock_search_response_similar_only) + ) + + result = capture_client.search_asset(nid=TEST_NID) + + # Filter out the exact match + other_matches = [m for m in result.similar_matches if m.nid != TEST_NID] + assert len(other_matches) > 0 + assert "bafybei444" in [m.nid for m in other_matches] + + @respx.mock + def test_search_asset_handles_empty_similar_matches( + self, + capture_client, + ): + """Verify SDK handles empty similar_matches gracefully.""" + response_empty = { + "precise_match": TEST_NID, + "input_file_mime_type": "image/png", + "similar_matches": [], + "order_id": "order_789", + } + + respx.post(ASSET_SEARCH_API_URL).mock( + return_value=Response(200, json=response_empty) + ) + + result = capture_client.search_asset(nid=TEST_NID) + + assert result.precise_match == TEST_NID + assert len(result.similar_matches) == 0 + + +class TestAssetSearchTypes: + """Tests for asset search types.""" + + def test_asset_search_result_has_all_fields(self): + """Verify AssetSearchResult has all required fields.""" + result = AssetSearchResult( + precise_match=TEST_NID, + input_file_mime_type="image/png", + similar_matches=[ + SimilarMatch(nid="bafybei111", distance=0.05), + ], + order_id="order_123", + ) + + assert result.precise_match == TEST_NID + assert result.input_file_mime_type == "image/png" + assert len(result.similar_matches) == 1 + assert result.order_id == "order_123" + + def test_similar_match_has_nid_and_distance(self): + """Verify SimilarMatch has nid and distance fields.""" + match = SimilarMatch(nid=TEST_NID, distance=0.05) + + assert match.nid == TEST_NID + assert match.distance == 0.05 + + +class TestAssetSearchValidation: + """Tests for asset search input validation.""" + + def test_search_asset_requires_input_source(self, capture_client): + """Verify search_asset raises error when no input source is provided.""" + from capture_sdk.errors import ValidationError + + with pytest.raises( + ValidationError, + match="Must provide file_url, file, or nid for asset search", + ): + capture_client.search_asset() + + def test_search_asset_validates_threshold(self, capture_client): + """Verify search_asset validates threshold range.""" + from capture_sdk.errors import ValidationError + + with pytest.raises( + ValidationError, + match="threshold must be between 0 and 1", + ): + capture_client.search_asset(nid=TEST_NID, threshold=1.5) + + def test_search_asset_validates_sample_count(self, capture_client): + """Verify search_asset validates sample_count is positive.""" + from capture_sdk.errors import ValidationError + + with pytest.raises( + ValidationError, + match="sample_count must be a positive integer", + ): + capture_client.search_asset(nid=TEST_NID, sample_count=0) diff --git a/python/tests/test_asset_tree.py b/python/tests/test_asset_tree.py new file mode 100644 index 0000000..86c45cb --- /dev/null +++ b/python/tests/test_asset_tree.py @@ -0,0 +1,359 @@ +""" +Unit tests for asset tree parsing following the AssetTree specification. + +These tests verify that the SDK correctly parses asset tree responses +including all fields from the Numbers Protocol AssetTree spec. + +See: https://docs.numbersprotocol.io/introduction/numbers-protocol/defining-web3-assets/assettree +""" + +import pytest +import respx +from httpx import Response + +from capture_sdk import Capture, License +from capture_sdk.types import AssetTree + +# Test asset NID +TEST_NID = "bafybeif3mhxhkhfwuszl2lybtai3hz3q6naqpfisd4q55mcc7opkmiv5ei" + +# Expected values +EXPECTED_CREATOR_WALLET = "0x019F590C900c78060da8597186d065ee514931BB" +EXPECTED_NFT_RECORD = "bafkreibjj4sgpeirznei5or3lncndzije6nw4qsksoomsbu23ivp7bdwei" + +# Mock API URLs +HISTORY_API_URL = "https://e23hi68y55.execute-api.us-east-1.amazonaws.com/default/get-commits-storage-backend-jade-near" +MERGE_TREE_API_URL = "https://us-central1-numbers-protocol-api.cloudfunctions.net/get-full-asset-tree" + + +@pytest.fixture +def capture_client(): + """Create a Capture client with test token.""" + return Capture(token="test-token") + + +@pytest.fixture +def mock_history_response(): + """Mock response for get_history API.""" + return { + "nid": TEST_NID, + "commits": [ + { + "assetTreeCid": "bafyreif123", + "txHash": "0xabc123", + "author": EXPECTED_CREATOR_WALLET, + "committer": EXPECTED_CREATOR_WALLET, + "timestampCreated": 1700000000, + "action": "create", + } + ], + } + + +@pytest.fixture +def mock_merged_tree_response(): + """Mock response for merged asset tree API with all spec fields.""" + return { + "mergedAssetTree": { + "assetCid": "bafybei123", + "assetSha256": "abc123def456", + "creatorName": "Test Creator", + "creatorWallet": EXPECTED_CREATOR_WALLET, + "createdAt": 1700000000, + "locationCreated": "Taiwan", + "caption": "Test caption", + "headline": "Test headline", + "license": { + "name": "CC BY 4.0", + "document": "https://creativecommons.org/licenses/by/4.0/", + }, + "mimeType": "image/png", + "nftRecord": EXPECTED_NFT_RECORD, + "usedBy": "https://example.com", + "integrityCid": "bafybeiintegrity123", + "digitalSourceType": "digitalCapture", + "miningPreference": "opt-in", + "generatedBy": "human", + "extraField": "extra value", + }, + "assetTrees": [], + } + + +class TestAssetTreeParsing: + """Tests for asset tree parsing.""" + + @respx.mock + def test_get_asset_tree_parses_creator_wallet( + self, + capture_client, + mock_history_response, + mock_merged_tree_response, + ): + """Verify creator_wallet is correctly parsed from API response.""" + respx.get(HISTORY_API_URL).mock( + return_value=Response(200, json=mock_history_response) + ) + respx.post(MERGE_TREE_API_URL).mock( + return_value=Response(200, json=mock_merged_tree_response) + ) + + tree = capture_client.get_asset_tree(TEST_NID) + + assert tree.creator_wallet == EXPECTED_CREATOR_WALLET + + @respx.mock + def test_get_asset_tree_parses_nft_record( + self, + capture_client, + mock_history_response, + mock_merged_tree_response, + ): + """Verify nft_record is correctly parsed from API response.""" + respx.get(HISTORY_API_URL).mock( + return_value=Response(200, json=mock_history_response) + ) + respx.post(MERGE_TREE_API_URL).mock( + return_value=Response(200, json=mock_merged_tree_response) + ) + + tree = capture_client.get_asset_tree(TEST_NID) + + assert tree.nft_record == EXPECTED_NFT_RECORD + + @respx.mock + def test_get_asset_tree_parses_license_object( + self, + capture_client, + mock_history_response, + mock_merged_tree_response, + ): + """Verify license is correctly parsed as an object with name and document.""" + respx.get(HISTORY_API_URL).mock( + return_value=Response(200, json=mock_history_response) + ) + respx.post(MERGE_TREE_API_URL).mock( + return_value=Response(200, json=mock_merged_tree_response) + ) + + tree = capture_client.get_asset_tree(TEST_NID) + + assert tree.license is not None + assert tree.license.name == "CC BY 4.0" + assert tree.license.document == "https://creativecommons.org/licenses/by/4.0/" + + @respx.mock + def test_get_asset_tree_parses_license_string_backwards_compat( + self, + capture_client, + mock_history_response, + ): + """Verify license string is converted to License object for backwards compatibility.""" + response_with_string_license = { + "mergedAssetTree": { + "assetCid": "bafybei123", + "creatorWallet": EXPECTED_CREATOR_WALLET, + "license": "MIT License", + }, + "assetTrees": [], + } + + respx.get(HISTORY_API_URL).mock( + return_value=Response(200, json=mock_history_response) + ) + respx.post(MERGE_TREE_API_URL).mock( + return_value=Response(200, json=response_with_string_license) + ) + + tree = capture_client.get_asset_tree(TEST_NID) + + assert tree.license is not None + assert tree.license.name == "MIT License" + assert tree.license.document is None + + @respx.mock + def test_get_asset_tree_parses_new_spec_fields( + self, + capture_client, + mock_history_response, + mock_merged_tree_response, + ): + """Verify new AssetTree spec fields are correctly parsed.""" + respx.get(HISTORY_API_URL).mock( + return_value=Response(200, json=mock_history_response) + ) + respx.post(MERGE_TREE_API_URL).mock( + return_value=Response(200, json=mock_merged_tree_response) + ) + + tree = capture_client.get_asset_tree(TEST_NID) + + # Verify new fields from spec + assert tree.used_by == "https://example.com" + assert tree.integrity_cid == "bafybeiintegrity123" + assert tree.digital_source_type == "digitalCapture" + assert tree.mining_preference == "opt-in" + assert tree.generated_by == "human" + + @respx.mock + def test_get_asset_tree_parses_all_known_fields( + self, + capture_client, + mock_history_response, + mock_merged_tree_response, + ): + """Verify all known fields are correctly parsed.""" + respx.get(HISTORY_API_URL).mock( + return_value=Response(200, json=mock_history_response) + ) + respx.post(MERGE_TREE_API_URL).mock( + return_value=Response(200, json=mock_merged_tree_response) + ) + + tree = capture_client.get_asset_tree(TEST_NID) + + # Verify all known fields + assert tree.asset_cid == "bafybei123" + assert tree.asset_sha256 == "abc123def456" + assert tree.creator_name == "Test Creator" + assert tree.creator_wallet == EXPECTED_CREATOR_WALLET + assert tree.created_at == 1700000000 + assert tree.location_created == "Taiwan" + assert tree.caption == "Test caption" + assert tree.headline == "Test headline" + assert tree.license is not None + assert tree.license.name == "CC BY 4.0" + assert tree.mime_type == "image/png" + assert tree.nft_record == EXPECTED_NFT_RECORD + assert tree.used_by == "https://example.com" + assert tree.integrity_cid == "bafybeiintegrity123" + assert tree.digital_source_type == "digitalCapture" + assert tree.mining_preference == "opt-in" + assert tree.generated_by == "human" + + @respx.mock + def test_get_asset_tree_stores_unknown_fields_in_extra( + self, + capture_client, + mock_history_response, + mock_merged_tree_response, + ): + """Verify unknown fields are stored in the extra dict.""" + respx.get(HISTORY_API_URL).mock( + return_value=Response(200, json=mock_history_response) + ) + respx.post(MERGE_TREE_API_URL).mock( + return_value=Response(200, json=mock_merged_tree_response) + ) + + tree = capture_client.get_asset_tree(TEST_NID) + + # Verify extra field is in the extra dict + assert "extraField" in tree.extra + assert tree.extra["extraField"] == "extra value" + + @respx.mock + def test_get_asset_tree_handles_missing_optional_fields( + self, + capture_client, + mock_history_response, + ): + """Verify SDK handles missing optional fields gracefully.""" + minimal_response = { + "mergedAssetTree": { + "assetCid": "bafybei123", + "creatorWallet": EXPECTED_CREATOR_WALLET, + }, + "assetTrees": [], + } + + respx.get(HISTORY_API_URL).mock( + return_value=Response(200, json=mock_history_response) + ) + respx.post(MERGE_TREE_API_URL).mock( + return_value=Response(200, json=minimal_response) + ) + + tree = capture_client.get_asset_tree(TEST_NID) + + assert tree.asset_cid == "bafybei123" + assert tree.creator_wallet == EXPECTED_CREATOR_WALLET + assert tree.nft_record is None + assert tree.license is None + assert tree.used_by is None + assert tree.integrity_cid is None + assert tree.digital_source_type is None + assert tree.mining_preference is None + assert tree.generated_by is None + + +class TestAssetTreeType: + """Tests for AssetTree dataclass.""" + + def test_asset_tree_has_all_spec_fields(self): + """Verify AssetTree has all fields from the spec.""" + tree = AssetTree( + asset_cid="bafybei123", + asset_sha256="abc123", + creator_name="Test Creator", + creator_wallet=EXPECTED_CREATOR_WALLET, + created_at=1700000000, + location_created="Taiwan", + caption="Test caption", + headline="Test headline", + license=License(name="CC BY 4.0", document="https://example.com"), + mime_type="image/png", + nft_record=EXPECTED_NFT_RECORD, + used_by="https://example.com", + integrity_cid="bafybeiintegrity123", + digital_source_type="digitalCapture", + mining_preference="opt-in", + generated_by="human", + ) + + assert tree.asset_cid == "bafybei123" + assert tree.nft_record == EXPECTED_NFT_RECORD + assert tree.license.name == "CC BY 4.0" + assert tree.used_by == "https://example.com" + assert tree.digital_source_type == "digitalCapture" + + def test_asset_tree_fields_default_to_none(self): + """Verify all optional fields default to None.""" + tree = AssetTree() + + assert tree.asset_cid is None + assert tree.nft_record is None + assert tree.license is None + assert tree.used_by is None + assert tree.integrity_cid is None + assert tree.digital_source_type is None + assert tree.mining_preference is None + assert tree.generated_by is None + + +class TestLicenseType: + """Tests for License dataclass.""" + + def test_license_has_name_and_document(self): + """Verify License has name and document fields.""" + license_info = License( + name="CC BY 4.0", + document="https://creativecommons.org/licenses/by/4.0/", + ) + + assert license_info.name == "CC BY 4.0" + assert license_info.document == "https://creativecommons.org/licenses/by/4.0/" + + def test_license_fields_default_to_none(self): + """Verify License fields default to None.""" + license_info = License() + + assert license_info.name is None + assert license_info.document is None + + def test_license_with_name_only(self): + """Verify License can be created with name only.""" + license_info = License(name="MIT") + + assert license_info.name == "MIT" + assert license_info.document is None diff --git a/python/tests/test_integration.py b/python/tests/test_integration.py new file mode 100644 index 0000000..28f1d40 --- /dev/null +++ b/python/tests/test_integration.py @@ -0,0 +1,179 @@ +""" +Integration tests for Capture SDK. + +These tests verify that the SDK correctly retrieves and parses +data from the Numbers Protocol API. + +Run with: pytest tests/test_integration.py -v +Set CAPTURE_TOKEN environment variable for live API tests. +""" + +import os + +import pytest + +from capture_sdk import Capture + +# Test asset NID +TEST_NID = "bafybeif3mhxhkhfwuszl2lybtai3hz3q6naqpfisd4q55mcc7opkmiv5ei" + +# Expected values for Test 1 +EXPECTED_CREATOR_WALLET = "0x019F590C900c78060da8597186d065ee514931BB" +EXPECTED_NFT_RECORD = "bafkreibjj4sgpeirznei5or3lncndzije6nw4qsksoomsbu23ivp7bdwei" + + +@pytest.fixture +def capture_client(): + """Create a Capture client using CAPTURE_TOKEN from environment.""" + token = os.environ.get("CAPTURE_TOKEN") + if not token: + pytest.skip("CAPTURE_TOKEN environment variable is required") + return Capture(token=token) + + +class TestAssetTree: + """Test 1: Verify asset tree contains correct creatorWallet and nftRecord.""" + + def test_get_asset_tree_returns_creator_wallet(self, capture_client): + """Verify asset tree contains correct creatorWallet.""" + tree = capture_client.get_asset_tree(TEST_NID) + + assert tree.creator_wallet is not None, "creatorWallet should not be None" + assert tree.creator_wallet == EXPECTED_CREATOR_WALLET, ( + f"creatorWallet mismatch: expected {EXPECTED_CREATOR_WALLET}, " + f"got {tree.creator_wallet}" + ) + + def test_get_asset_tree_returns_nft_record(self, capture_client): + """Verify asset tree contains correct nftRecord.""" + tree = capture_client.get_asset_tree(TEST_NID) + + assert tree.nft_record is not None, "nftRecord should not be None" + assert tree.nft_record == EXPECTED_NFT_RECORD, ( + f"nftRecord mismatch: expected {EXPECTED_NFT_RECORD}, " + f"got {tree.nft_record}" + ) + + def test_get_asset_tree_returns_all_fields(self, capture_client): + """Verify asset tree contains all expected fields.""" + tree = capture_client.get_asset_tree(TEST_NID) + + # Print asset tree for debugging + print(f"\nAsset Tree for {TEST_NID}:") + print(f" asset_cid: {tree.asset_cid}") + print(f" creator_name: {tree.creator_name}") + print(f" creator_wallet: {tree.creator_wallet}") + print(f" nft_record: {tree.nft_record}") + print(f" mime_type: {tree.mime_type}") + print(f" extra keys: {list(tree.extra.keys())}") + + # Verify key fields exist + assert tree.creator_wallet == EXPECTED_CREATOR_WALLET + assert tree.nft_record == EXPECTED_NFT_RECORD + + +class TestAssetSearch: + """Test 2: Verify asset search returns correct results.""" + + def test_search_asset_by_nid(self, capture_client): + """Search for asset by NID and verify results.""" + result = capture_client.search_asset(nid=TEST_NID) + + print(f"\nSearch Results for {TEST_NID}:") + print(f" precise_match: {result.precise_match}") + print(f" input_file_mime_type: {result.input_file_mime_type}") + print(f" similar_matches count: {len(result.similar_matches)}") + + if result.similar_matches: + print("\n Similar matches (top 5):") + for i, match in enumerate(result.similar_matches[:5]): + print(f" {i + 1}. {match.nid} (distance: {match.distance})") + + # The search should return results + assert result.order_id, "order_id should not be empty" + + def test_search_asset_by_file(self, capture_client, tmp_path): + """Search for asset using a file and verify results contain the expected NID.""" + # Skip if no test image is provided via environment + test_image_path = os.environ.get("TEST_IMAGE_PATH") + if not test_image_path: + pytest.skip("TEST_IMAGE_PATH environment variable not set") + + result = capture_client.search_asset(file=test_image_path) + + print(f"\nSearch Results for image {test_image_path}:") + print(f" precise_match: {result.precise_match}") + print(f" input_file_mime_type: {result.input_file_mime_type}") + print(f" similar_matches count: {len(result.similar_matches)}") + + # Check if expected NID is in results + found_exact = result.precise_match == TEST_NID + found_similar = any(m.nid == TEST_NID for m in result.similar_matches) + + assert found_exact or found_similar, ( + f"Expected NID {TEST_NID} not found in results" + ) + + if found_exact: + print(f"\n Found exact match: {TEST_NID}") + else: + print(f"\n Found in similar matches: {TEST_NID}") + + # Verify there are other similar assets + other_matches = [m for m in result.similar_matches if m.nid != TEST_NID] + if other_matches: + print(f" Found {len(other_matches)} other similar assets") + + def test_search_asset_returns_similar_matches(self, capture_client): + """Verify search returns similar matches.""" + result = capture_client.search_asset(nid=TEST_NID, sample_count=10) + + # Should have some results + print(f"\nSimilar matches for {TEST_NID}:") + for match in result.similar_matches: + print(f" - {match.nid}: distance={match.distance}") + + # Note: The presence of similar matches depends on the asset and index + # We just verify the API responds correctly + assert isinstance(result.similar_matches, list) + + +def test_full_workflow(capture_client): + """ + Full workflow test: Get asset tree and verify all expected data. + + This test verifies: + 1. Asset tree contains creatorWallet: 0x019F590C900c78060da8597186d065ee514931BB + 2. Asset tree contains nftRecord: bafkreibjj4sgpeirznei5or3lncndzije6nw4qsksoomsbu23ivp7bdwei + """ + print(f"\n{'=' * 60}") + print("Full Workflow Test") + print(f"{'=' * 60}") + + # Get asset tree + tree = capture_client.get_asset_tree(TEST_NID) + + # Print all retrieved data + print("\nAsset Tree Data:") + print(f" NID: {TEST_NID}") + print(f" Creator Wallet: {tree.creator_wallet}") + print(f" NFT Record: {tree.nft_record}") + print(f" Creator Name: {tree.creator_name}") + print(f" Mime Type: {tree.mime_type}") + + # Verify expected values + print("\nVerification:") + print(f" Expected Creator Wallet: {EXPECTED_CREATOR_WALLET}") + print(f" Actual Creator Wallet: {tree.creator_wallet}") + print(f" Match: {tree.creator_wallet == EXPECTED_CREATOR_WALLET}") + + print(f"\n Expected NFT Record: {EXPECTED_NFT_RECORD}") + print(f" Actual NFT Record: {tree.nft_record}") + print(f" Match: {tree.nft_record == EXPECTED_NFT_RECORD}") + + assert tree.creator_wallet == EXPECTED_CREATOR_WALLET + assert tree.nft_record == EXPECTED_NFT_RECORD + + print(f"\n{'=' * 60}") + print("All verifications passed!") + print(f"{'=' * 60}") diff --git a/scripts/check-feature-parity.py b/scripts/check-feature-parity.py index dcc1228..e8a2226 100644 --- a/scripts/check-feature-parity.py +++ b/scripts/check-feature-parity.py @@ -41,6 +41,7 @@ class Feature: "Type.Asset": Feature("Asset"), "Type.Commit": Feature("Commit"), "Type.AssetTree": Feature("AssetTree"), + "Type.License": Feature("License"), "Type.AssetSearchOptions": Feature("AssetSearchOptions"), "Type.AssetSearchResult": Feature("AssetSearchResult"), "Type.SimilarMatch": Feature("SimilarMatch"), diff --git a/ts/package-lock.json b/ts/package-lock.json index dcc1816..1ca3a0b 100644 --- a/ts/package-lock.json +++ b/ts/package-lock.json @@ -1,11 +1,11 @@ { - "name": "capture-sdk", + "name": "@numbersprotocol/capture-sdk", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "capture-sdk", + "name": "@numbersprotocol/capture-sdk", "version": "0.1.0", "license": "MIT", "dependencies": { diff --git a/ts/package.json b/ts/package.json index a15f716..8652018 100644 --- a/ts/package.json +++ b/ts/package.json @@ -1,6 +1,6 @@ { "name": "@numbersprotocol/capture-sdk", - "version": "0.1.0", + "version": "0.2.0", "description": "TypeScript SDK for Numbers Protocol Capture API", "type": "module", "main": "./dist/index.cjs", diff --git a/ts/src/integration.test.ts b/ts/src/integration.test.ts new file mode 100644 index 0000000..611f967 --- /dev/null +++ b/ts/src/integration.test.ts @@ -0,0 +1,179 @@ +/** + * Integration tests for Capture SDK. + * + * These tests verify that the SDK correctly retrieves and parses + * data from the Numbers Protocol API. + * + * Run with: npx ts-node src/integration.test.ts + * Or set CAPTURE_TOKEN environment variable for live API tests. + */ + +import { Capture } from './client.js' + +// Test asset NID +const TEST_NID = 'bafybeif3mhxhkhfwuszl2lybtai3hz3q6naqpfisd4q55mcc7opkmiv5ei' + +// Expected values for Test 1 +const EXPECTED_CREATOR_WALLET = '0x019F590C900c78060da8597186d065ee514931BB' +const EXPECTED_NFT_RECORD = 'bafkreibjj4sgpeirznei5or3lncndzije6nw4qsksoomsbu23ivp7bdwei' + +/** + * Test 1: Verify asset tree contains correct creatorWallet and nftRecord + */ +async function testAssetTree(): Promise { + console.log('=== Test 1: Asset Tree Verification ===') + console.log(`Testing NID: ${TEST_NID}`) + + const token = process.env.CAPTURE_TOKEN + if (!token) { + console.error('Error: CAPTURE_TOKEN environment variable is required') + return false + } + + const capture = new Capture({ token }) + + try { + const tree = await capture.getAssetTree(TEST_NID) + + console.log('\nAsset Tree Response:') + console.log(` assetCid: ${tree.assetCid}`) + console.log(` creatorName: ${tree.creatorName}`) + console.log(` creatorWallet: ${tree.creatorWallet}`) + console.log(` nftRecord: ${tree.nftRecord}`) + console.log(` mimeType: ${tree.mimeType}`) + + // Verify creatorWallet + if (tree.creatorWallet !== EXPECTED_CREATOR_WALLET) { + console.error( + `\nFAILED: creatorWallet mismatch\n` + + ` Expected: ${EXPECTED_CREATOR_WALLET}\n` + + ` Got: ${tree.creatorWallet}` + ) + return false + } + console.log('\n✓ creatorWallet matches expected value') + + // Verify nftRecord + if (tree.nftRecord !== EXPECTED_NFT_RECORD) { + console.error( + `\nFAILED: nftRecord mismatch\n` + + ` Expected: ${EXPECTED_NFT_RECORD}\n` + + ` Got: ${tree.nftRecord}` + ) + return false + } + console.log('✓ nftRecord matches expected value') + + console.log('\n=== Test 1 PASSED ===\n') + return true + } catch (error) { + console.error('Test 1 failed with error:', error) + return false + } +} + +/** + * Test 2: Verify asset search returns correct results + */ +async function testAssetSearch(imagePath?: string): Promise { + console.log('=== Test 2: Verify Engine Asset Search ===') + + const token = process.env.CAPTURE_TOKEN + if (!token) { + console.error('Error: CAPTURE_TOKEN environment variable is required') + return false + } + + const capture = new Capture({ token }) + + try { + let result + + if (imagePath) { + console.log(`Searching with file: ${imagePath}`) + result = await capture.searchAsset({ file: imagePath }) + } else { + console.log(`Searching with NID: ${TEST_NID}`) + result = await capture.searchAsset({ nid: TEST_NID }) + } + + console.log('\nSearch Results:') + console.log(` preciseMatch: ${result.preciseMatch}`) + console.log(` inputFileMimeType: ${result.inputFileMimeType}`) + console.log(` orderId: ${result.orderId}`) + console.log(` similarMatches count: ${result.similarMatches.length}`) + + if (result.similarMatches.length > 0) { + console.log('\n Similar matches:') + result.similarMatches.slice(0, 5).forEach((match, i) => { + console.log(` ${i + 1}. ${match.nid} (distance: ${match.distance})`) + }) + } + + // If searching by file, verify the expected NID is in results + if (imagePath) { + // Check if precise match or similar matches contain the expected NID + const foundExact = result.preciseMatch === TEST_NID + const foundSimilar = result.similarMatches.some((m) => m.nid === TEST_NID) + + if (!foundExact && !foundSimilar) { + console.error( + `\nFAILED: Expected NID ${TEST_NID} not found in results` + ) + return false + } + + if (foundExact) { + console.log(`\n✓ Found exact match: ${TEST_NID}`) + } else { + console.log(`\n✓ Found in similar matches: ${TEST_NID}`) + } + + // Verify there are other similar assets + const otherMatches = result.similarMatches.filter( + (m) => m.nid !== TEST_NID + ) + if (otherMatches.length === 0 && !foundExact) { + console.warn('Warning: No other similar assets found') + } else { + console.log( + `✓ Found ${otherMatches.length} other similar assets` + ) + } + } + + console.log('\n=== Test 2 PASSED ===\n') + return true + } catch (error) { + console.error('Test 2 failed with error:', error) + return false + } +} + +/** + * Run all tests + */ +async function runTests(): Promise { + console.log('Capture SDK Integration Tests\n') + console.log('==============================\n') + + const imagePath = process.argv[2] // Optional image path from command line + + const results = await Promise.all([ + testAssetTree(), + testAssetSearch(imagePath), + ]) + + console.log('\n==============================') + console.log('Test Summary:') + console.log(` Test 1 (Asset Tree): ${results[0] ? 'PASSED' : 'FAILED'}`) + console.log(` Test 2 (Asset Search): ${results[1] ? 'PASSED' : 'FAILED'}`) + console.log('==============================\n') + + if (!results.every((r) => r)) { + process.exit(1) + } +} + +// Run tests if this file is executed directly +runTests() diff --git a/ts/src/types.ts b/ts/src/types.ts index 12f181c..bc57312 100644 --- a/ts/src/types.ts +++ b/ts/src/types.ts @@ -92,26 +92,54 @@ export interface Commit { action: string } +/** + * License information for an asset. + */ +export interface License { + /** License name (e.g., "CC BY 4.0") */ + name?: string + /** URL to the license document */ + document?: string +} + /** * Merged asset tree containing full provenance data. + * Follows the Numbers Protocol AssetTree specification. + * @see https://docs.numbersprotocol.io/introduction/numbers-protocol/defining-web3-assets/assettree */ export interface AssetTree { - /** Asset content identifiers */ + /** Asset content identifier (IPFS CID) */ assetCid?: string + /** SHA-256 hash of the asset file */ assetSha256?: string - /** Creator information */ + /** Creator's name */ creatorName?: string + /** Creator's wallet address */ creatorWallet?: string - /** Creation metadata */ + /** Unix timestamp when asset was created */ createdAt?: number + /** Location where asset was created */ locationCreated?: string - /** Asset description */ + /** Asset description/abstract */ caption?: string + /** Asset title */ headline?: string /** License information */ - license?: string - /** MIME type */ + license?: License + /** MIME type (encodingFormat) */ mimeType?: string + /** NFT record CID (if asset has been minted as NFT) */ + nftRecord?: string + /** URL of website that uses the asset */ + usedBy?: string + /** IPFS CID of the integrity proof */ + integrityCid?: string + /** Digital source type (e.g., digitalCapture, trainedAlgorithmicMedia) */ + digitalSourceType?: string + /** Mining/indexing preference */ + miningPreference?: string + /** AI/algorithm information for generated content */ + generatedBy?: string /** Additional fields from commits */ [key: string]: unknown }