diff --git a/python/tests/test_core_operations.py b/python/tests/test_core_operations.py new file mode 100644 index 0000000..a7c373a --- /dev/null +++ b/python/tests/test_core_operations.py @@ -0,0 +1,737 @@ +""" +Unit tests for core SDK operations: register, update, get, get_history, search_nft, +and _normalize_file edge cases. + +These tests verify request construction, response parsing, and error handling +using mocked HTTP responses via respx. +""" + +from pathlib import Path + +import pytest +import respx +from httpx import Response + +from numbersprotocol_capture import Capture +from numbersprotocol_capture.client import _normalize_file +from numbersprotocol_capture.errors import ( + AuthenticationError, + NotFoundError, + NetworkError, + ValidationError, +) +from numbersprotocol_capture.types import Asset, Commit, NftRecord + +# Test asset NID +TEST_NID = "bafybeif3mhxhkhfwuszl2lybtai3hz3q6naqpfisd4q55mcc7opkmiv5ei" + +# Mock API URLs +BASE_URL = "https://api.numbersprotocol.io/api/v3" +ASSETS_URL = f"{BASE_URL}/assets/" +ASSET_URL = f"{BASE_URL}/assets/{TEST_NID}/" +HISTORY_API_URL = "https://e23hi68y55.execute-api.us-east-1.amazonaws.com/default/get-commits-storage-backend-jade-near" +NFT_SEARCH_API_URL = "https://eofveg1f59hrbn.m.pipedream.net" + +# Shared mock asset API response +MOCK_ASSET_RESPONSE = { + "id": TEST_NID, + "asset_file_name": "test.png", + "asset_file_mime_type": "image/png", + "caption": "Test caption", + "headline": "Test headline", +} + +MOCK_HISTORY_RESPONSE = { + "nid": TEST_NID, + "commits": [ + { + "assetTreeCid": "bafyreif123", + "txHash": "0xabc123", + "author": "0x1234", + "committer": "0x1234", + "timestampCreated": 1700000000, + "action": "create", + } + ], +} + + +@pytest.fixture +def capture_client(): + """Create a Capture client with test token.""" + with Capture(token="test-token") as client: + yield client + + +# --------------------------------------------------------------------------- +# register() +# --------------------------------------------------------------------------- + + +class TestRegister: + """Tests for register() request construction and response parsing.""" + + @respx.mock + def test_register_posts_to_assets_endpoint(self, capture_client): + """Verify register() POSTs to the correct endpoint.""" + route = respx.post(ASSETS_URL).mock( + return_value=Response(201, json=MOCK_ASSET_RESPONSE) + ) + + capture_client.register(b"test content", filename="test.png") + + assert route.called + assert route.call_count == 1 + + @respx.mock + def test_register_sends_authorization_header(self): + """Verify Authorization header is set correctly.""" + route = respx.post(ASSETS_URL).mock( + return_value=Response(201, json=MOCK_ASSET_RESPONSE) + ) + + with Capture(token="my-secret-token") as capture: + capture.register(b"test content", filename="test.png") + + request = route.calls[0].request + assert request.headers["Authorization"] == "token my-secret-token" + + @respx.mock + def test_register_includes_asset_file_in_multipart(self, capture_client): + """Verify asset_file is included in multipart form data.""" + route = respx.post(ASSETS_URL).mock( + return_value=Response(201, json=MOCK_ASSET_RESPONSE) + ) + + capture_client.register(b"image data", filename="photo.png") + + request = route.calls[0].request + content_type = request.headers.get("content-type", "") + assert "multipart/form-data" in content_type + body = request.content.decode("latin-1") + assert "asset_file" in body + assert "photo.png" in body + + @respx.mock + def test_register_includes_caption_and_headline(self, capture_client): + """Verify caption and headline are sent in the form data.""" + route = respx.post(ASSETS_URL).mock( + return_value=Response(201, json=MOCK_ASSET_RESPONSE) + ) + + capture_client.register( + b"image data", + filename="photo.png", + caption="My caption", + headline="My title", + ) + + request = route.calls[0].request + body = request.content.decode("latin-1") + assert "My caption" in body + assert "My title" in body + + @respx.mock + def test_register_sets_public_access_true_by_default(self, capture_client): + """Verify public_access defaults to true.""" + route = respx.post(ASSETS_URL).mock( + return_value=Response(201, json=MOCK_ASSET_RESPONSE) + ) + + capture_client.register(b"image data", filename="photo.png") + + request = route.calls[0].request + body = request.content.decode("latin-1") + assert "true" in body + + @respx.mock + def test_register_parses_asset_response(self, capture_client): + """Verify register() correctly parses the API response.""" + respx.post(ASSETS_URL).mock( + return_value=Response(201, json=MOCK_ASSET_RESPONSE) + ) + + asset = capture_client.register(b"image data", filename="photo.png") + + assert asset.nid == TEST_NID + assert asset.filename == "test.png" + assert asset.mime_type == "image/png" + assert asset.caption == "Test caption" + assert asset.headline == "Test headline" + + @respx.mock + def test_register_with_path_object(self, tmp_path, capture_client): + """Verify register() accepts a Path object.""" + test_file = tmp_path / "image.png" + test_file.write_bytes(b"fake png data") + + respx.post(ASSETS_URL).mock( + return_value=Response(201, json=MOCK_ASSET_RESPONSE) + ) + + asset = capture_client.register(test_file) + assert asset.nid == TEST_NID + + @respx.mock + def test_register_raises_authentication_error_on_401(self, capture_client): + """Verify AuthenticationError is raised on 401 response.""" + respx.post(ASSETS_URL).mock(return_value=Response(401, json={"detail": "Unauthorized"})) + + with pytest.raises(AuthenticationError): + capture_client.register(b"image data", filename="photo.png") + + @respx.mock + def test_register_raises_validation_error_on_empty_file(self, capture_client): + """Verify ValidationError is raised for empty file content.""" + with pytest.raises(ValidationError, match="file cannot be empty"): + capture_client.register(b"", filename="empty.png") + + def test_register_raises_validation_error_for_headline_too_long(self, capture_client): + """Verify ValidationError is raised for headline over 25 characters.""" + with pytest.raises(ValidationError, match="headline must be 25 characters or less"): + capture_client.register(b"data", filename="test.png", headline="a" * 26) + + def test_register_raises_validation_error_for_bytes_without_filename(self, capture_client): + """Verify ValidationError is raised for bytes input without filename.""" + with pytest.raises(ValidationError, match="filename is required"): + capture_client.register(b"data") + + +# --------------------------------------------------------------------------- +# update() +# --------------------------------------------------------------------------- + + +class TestUpdate: + """Tests for update() request construction and response parsing.""" + + @respx.mock + def test_update_patches_correct_endpoint(self, capture_client): + """Verify update() sends PATCH to the correct URL.""" + route = respx.patch(ASSET_URL).mock( + return_value=Response(200, json=MOCK_ASSET_RESPONSE) + ) + + capture_client.update(TEST_NID, caption="Updated caption") + + assert route.called + request = route.calls[0].request + assert request.method == "PATCH" + + @respx.mock + def test_update_sends_authorization_header(self): + """Verify Authorization header is set correctly.""" + route = respx.patch(ASSET_URL).mock( + return_value=Response(200, json=MOCK_ASSET_RESPONSE) + ) + + with Capture(token="update-token") as capture: + capture.update(TEST_NID, caption="test") + + request = route.calls[0].request + assert request.headers["Authorization"] == "token update-token" + + @respx.mock + def test_update_sends_caption_in_form_data(self, capture_client): + """Verify caption is included in the PATCH request.""" + from urllib.parse import unquote_plus + + route = respx.patch(ASSET_URL).mock( + return_value=Response(200, json=MOCK_ASSET_RESPONSE) + ) + + capture_client.update(TEST_NID, caption="New caption") + + request = route.calls[0].request + body = unquote_plus(request.content.decode("utf-8")) + assert "New caption" in body + + @respx.mock + def test_update_sends_headline_and_commit_message(self, capture_client): + """Verify headline and commit_message are sent.""" + from urllib.parse import unquote_plus + + route = respx.patch(ASSET_URL).mock( + return_value=Response(200, json=MOCK_ASSET_RESPONSE) + ) + + capture_client.update( + TEST_NID, + headline="New title", + commit_message="Fixed typo", + ) + + request = route.calls[0].request + body = unquote_plus(request.content.decode("utf-8")) + assert "New title" in body + assert "Fixed typo" in body + + @respx.mock + def test_update_serializes_custom_metadata_as_json(self, capture_client): + """Verify custom_metadata is serialized to JSON in nit_commit_custom field.""" + route = respx.patch(ASSET_URL).mock( + return_value=Response(200, json=MOCK_ASSET_RESPONSE) + ) + + capture_client.update( + TEST_NID, + custom_metadata={"source": "camera", "location": "Taiwan"}, + ) + + request = route.calls[0].request + body = request.content.decode("utf-8") + assert "nit_commit_custom" in body + assert "camera" in body + assert "Taiwan" in body + + @respx.mock + def test_update_parses_asset_response(self, capture_client): + """Verify update() correctly parses the API response.""" + updated_response = {**MOCK_ASSET_RESPONSE, "caption": "Updated caption"} + respx.patch(ASSET_URL).mock(return_value=Response(200, json=updated_response)) + + asset = capture_client.update(TEST_NID, caption="Updated caption") + + assert asset.nid == TEST_NID + assert asset.caption == "Updated caption" + + def test_update_raises_validation_error_for_empty_nid(self, capture_client): + """Verify ValidationError is raised for empty NID.""" + with pytest.raises(ValidationError, match="nid is required"): + capture_client.update("", caption="test") + + def test_update_raises_validation_error_for_long_headline(self, capture_client): + """Verify ValidationError is raised for headline over 25 characters.""" + with pytest.raises(ValidationError, match="headline must be 25 characters or less"): + capture_client.update(TEST_NID, headline="a" * 26) + + @respx.mock + def test_update_raises_not_found_error_on_404(self, capture_client): + """Verify NotFoundError is raised on 404 response.""" + respx.patch(ASSET_URL).mock(return_value=Response(404, json={"detail": "Not found"})) + + with pytest.raises(NotFoundError): + capture_client.update(TEST_NID, caption="test") + + +# --------------------------------------------------------------------------- +# get() +# --------------------------------------------------------------------------- + + +class TestGet: + """Tests for get() request construction and response parsing.""" + + @respx.mock + def test_get_requests_correct_endpoint(self, capture_client): + """Verify get() sends GET to the correct URL.""" + route = respx.get(ASSET_URL).mock( + return_value=Response(200, json=MOCK_ASSET_RESPONSE) + ) + + capture_client.get(TEST_NID) + + assert route.called + assert route.calls[0].request.method == "GET" + + @respx.mock + def test_get_sends_authorization_header(self): + """Verify Authorization header is set correctly.""" + route = respx.get(ASSET_URL).mock( + return_value=Response(200, json=MOCK_ASSET_RESPONSE) + ) + + with Capture(token="get-token") as capture: + capture.get(TEST_NID) + + request = route.calls[0].request + assert request.headers["Authorization"] == "token get-token" + + @respx.mock + def test_get_parses_asset_response(self, capture_client): + """Verify get() correctly parses the API response.""" + respx.get(ASSET_URL).mock(return_value=Response(200, json=MOCK_ASSET_RESPONSE)) + + asset = capture_client.get(TEST_NID) + + assert asset.nid == TEST_NID + assert asset.filename == "test.png" + assert asset.mime_type == "image/png" + assert asset.caption == "Test caption" + assert asset.headline == "Test headline" + assert isinstance(asset, Asset) + + def test_get_raises_validation_error_for_empty_nid(self, capture_client): + """Verify ValidationError is raised for empty NID.""" + with pytest.raises(ValidationError, match="nid is required"): + capture_client.get("") + + @respx.mock + def test_get_raises_not_found_error_on_404(self, capture_client): + """Verify NotFoundError is raised on 404 response.""" + respx.get(ASSET_URL).mock(return_value=Response(404, json={"detail": "Not found"})) + + with pytest.raises(NotFoundError): + capture_client.get(TEST_NID) + + @respx.mock + def test_get_raises_authentication_error_on_401(self, capture_client): + """Verify AuthenticationError is raised on 401 response.""" + respx.get(ASSET_URL).mock(return_value=Response(401, json={"detail": "Unauthorized"})) + + with pytest.raises(AuthenticationError): + capture_client.get(TEST_NID) + + @respx.mock + def test_get_uses_custom_base_url(self): + """Verify get() uses custom base URL when configured.""" + custom_url = "https://custom.api.com/v1/assets/{}/".format(TEST_NID) + route = respx.get(custom_url).mock( + return_value=Response(200, json=MOCK_ASSET_RESPONSE) + ) + + with Capture(token="test-token", base_url="https://custom.api.com/v1") as capture: + capture.get(TEST_NID) + + assert route.called + + +# --------------------------------------------------------------------------- +# get_history() +# --------------------------------------------------------------------------- + + +class TestGetHistory: + """Tests for get_history() request construction and response parsing.""" + + @respx.mock + def test_get_history_includes_nid_in_query(self, capture_client): + """Verify get_history() sends nid as query parameter.""" + route = respx.get(HISTORY_API_URL).mock( + return_value=Response(200, json=MOCK_HISTORY_RESPONSE) + ) + + capture_client.get_history(TEST_NID) + + assert route.called + request = route.calls[0].request + assert TEST_NID in str(request.url) + + @respx.mock + def test_get_history_includes_testnet_param_when_enabled(self): + """Verify testnet=true is added when testnet mode is on.""" + route = respx.get(HISTORY_API_URL).mock( + return_value=Response(200, json=MOCK_HISTORY_RESPONSE) + ) + + with Capture(token="test-token", testnet=True) as capture: + capture.get_history(TEST_NID) + + request = route.calls[0].request + assert "testnet=true" in str(request.url) + + @respx.mock + def test_get_history_excludes_testnet_param_by_default(self, capture_client): + """Verify testnet param is absent by default.""" + route = respx.get(HISTORY_API_URL).mock( + return_value=Response(200, json=MOCK_HISTORY_RESPONSE) + ) + + capture_client.get_history(TEST_NID) + + request = route.calls[0].request + assert "testnet" not in str(request.url) + + @respx.mock + def test_get_history_sends_authorization_header(self): + """Verify Authorization header is set correctly.""" + route = respx.get(HISTORY_API_URL).mock( + return_value=Response(200, json=MOCK_HISTORY_RESPONSE) + ) + + with Capture(token="history-token") as capture: + capture.get_history(TEST_NID) + + request = route.calls[0].request + assert request.headers["Authorization"] == "token history-token" + + @respx.mock + def test_get_history_parses_commits(self, capture_client): + """Verify commits are correctly parsed from response.""" + respx.get(HISTORY_API_URL).mock( + return_value=Response(200, json=MOCK_HISTORY_RESPONSE) + ) + + commits = capture_client.get_history(TEST_NID) + + assert len(commits) == 1 + commit = commits[0] + assert isinstance(commit, Commit) + assert commit.asset_tree_cid == "bafyreif123" + assert commit.tx_hash == "0xabc123" + assert commit.author == "0x1234" + assert commit.committer == "0x1234" + assert commit.timestamp == 1700000000 + assert commit.action == "create" + + @respx.mock + def test_get_history_parses_multiple_commits(self, capture_client): + """Verify multiple commits are all parsed.""" + multi_history = { + "nid": TEST_NID, + "commits": [ + { + "assetTreeCid": "bafyreif001", + "txHash": "0xhash001", + "author": "0xAuthor", + "committer": "0xAuthor", + "timestampCreated": 1700000000, + "action": "create", + }, + { + "assetTreeCid": "bafyreif002", + "txHash": "0xhash002", + "author": "0xAuthor", + "committer": "0xEditor", + "timestampCreated": 1700001000, + "action": "update", + }, + ], + } + respx.get(HISTORY_API_URL).mock(return_value=Response(200, json=multi_history)) + + commits = capture_client.get_history(TEST_NID) + + assert len(commits) == 2 + assert commits[1].action == "update" + + def test_get_history_raises_validation_error_for_empty_nid(self, capture_client): + """Verify ValidationError is raised for empty NID.""" + with pytest.raises(ValidationError, match="nid is required"): + capture_client.get_history("") + + @respx.mock + def test_get_history_raises_error_on_server_error(self, capture_client): + """Verify NetworkError is raised on 500 response.""" + respx.get(HISTORY_API_URL).mock(return_value=Response(500)) + + with pytest.raises(NetworkError): + capture_client.get_history(TEST_NID) + + +# --------------------------------------------------------------------------- +# search_nft() +# --------------------------------------------------------------------------- + + +class TestSearchNft: + """Tests for search_nft() request construction and response parsing.""" + + MOCK_NFT_RESPONSE = { + "records": [ + { + "token_id": "42", + "contract": "0xContract", + "network": "ethereum", + "owner": "0xOwner", + } + ], + "order_id": "nft-order-123", + } + + @respx.mock + def test_search_nft_posts_to_nft_endpoint(self, capture_client): + """Verify search_nft() POSTs to the NFT search endpoint.""" + route = respx.post(NFT_SEARCH_API_URL).mock( + return_value=Response(200, json=self.MOCK_NFT_RESPONSE) + ) + + capture_client.search_nft(TEST_NID) + + assert route.called + assert route.calls[0].request.method == "POST" + + @respx.mock + def test_search_nft_sends_nid_in_json_body(self, capture_client): + """Verify NID is sent in JSON body.""" + route = respx.post(NFT_SEARCH_API_URL).mock( + return_value=Response(200, json=self.MOCK_NFT_RESPONSE) + ) + + capture_client.search_nft(TEST_NID) + + import json + request = route.calls[0].request + body = json.loads(request.content) + assert body["nid"] == TEST_NID + + @respx.mock + def test_search_nft_sends_authorization_header(self): + """Verify Authorization header is set correctly.""" + route = respx.post(NFT_SEARCH_API_URL).mock( + return_value=Response(200, json=self.MOCK_NFT_RESPONSE) + ) + + with Capture(token="nft-token") as capture: + capture.search_nft(TEST_NID) + + request = route.calls[0].request + assert request.headers["Authorization"] == "token nft-token" + + @respx.mock + def test_search_nft_sends_content_type_json(self, capture_client): + """Verify Content-Type: application/json header is sent.""" + route = respx.post(NFT_SEARCH_API_URL).mock( + return_value=Response(200, json=self.MOCK_NFT_RESPONSE) + ) + + capture_client.search_nft(TEST_NID) + + request = route.calls[0].request + assert "application/json" in request.headers.get("content-type", "") + + @respx.mock + def test_search_nft_parses_nft_records(self, capture_client): + """Verify NFT records are correctly parsed from response.""" + respx.post(NFT_SEARCH_API_URL).mock( + return_value=Response(200, json=self.MOCK_NFT_RESPONSE) + ) + + result = capture_client.search_nft(TEST_NID) + + assert len(result.records) == 1 + record = result.records[0] + assert isinstance(record, NftRecord) + assert record.token_id == "42" + assert record.contract == "0xContract" + assert record.network == "ethereum" + assert record.owner == "0xOwner" + assert result.order_id == "nft-order-123" + + @respx.mock + def test_search_nft_handles_empty_records(self, capture_client): + """Verify empty records list is handled correctly.""" + respx.post(NFT_SEARCH_API_URL).mock( + return_value=Response(200, json={"records": [], "order_id": "empty-order"}) + ) + + result = capture_client.search_nft(TEST_NID) + + assert len(result.records) == 0 + assert result.order_id == "empty-order" + + def test_search_nft_raises_validation_error_for_empty_nid(self, capture_client): + """Verify ValidationError is raised for empty NID.""" + with pytest.raises(ValidationError, match="nid is required for NFT search"): + capture_client.search_nft("") + + @respx.mock + def test_search_nft_raises_authentication_error_on_401(self, capture_client): + """Verify AuthenticationError is raised on 401 response.""" + respx.post(NFT_SEARCH_API_URL).mock( + return_value=Response(401, json={"message": "Unauthorized"}) + ) + + with pytest.raises(AuthenticationError): + capture_client.search_nft(TEST_NID) + + @respx.mock + def test_search_nft_raises_network_error_on_500(self, capture_client): + """Verify NetworkError is raised on 500 response.""" + respx.post(NFT_SEARCH_API_URL).mock( + return_value=Response(500, json={"message": "Server error"}) + ) + + with pytest.raises(NetworkError): + capture_client.search_nft(TEST_NID) + + +# --------------------------------------------------------------------------- +# _normalize_file() +# --------------------------------------------------------------------------- + + +class TestNormalizeFile: + """Tests for _normalize_file() function with various input types.""" + + def test_normalize_str_path(self, tmp_path): + """Verify string path input is normalized correctly.""" + test_file = tmp_path / "photo.jpg" + test_file.write_bytes(b"jpeg data") + + data, filename, mime_type = _normalize_file(str(test_file)) + + assert data == b"jpeg data" + assert filename == "photo.jpg" + assert mime_type == "image/jpeg" + + def test_normalize_path_object(self, tmp_path): + """Verify Path object input is normalized correctly.""" + test_file = tmp_path / "image.png" + test_file.write_bytes(b"png data") + + data, filename, mime_type = _normalize_file(test_file) + + assert data == b"png data" + assert filename == "image.png" + assert mime_type == "image/png" + + def test_normalize_bytes_with_filename(self): + """Verify bytes input with filename option is normalized correctly.""" + from numbersprotocol_capture.types import RegisterOptions + + options = RegisterOptions(filename="video.mp4") + data, filename, mime_type = _normalize_file(b"video data", options) + + assert data == b"video data" + assert filename == "video.mp4" + assert mime_type == "video/mp4" + + def test_normalize_bytearray_with_filename(self): + """Verify bytearray input is treated the same as bytes.""" + from numbersprotocol_capture.types import RegisterOptions + + options = RegisterOptions(filename="audio.mp3") + data, filename, mime_type = _normalize_file(bytearray(b"audio data"), options) + + assert data == b"audio data" + assert filename == "audio.mp3" + assert mime_type == "audio/mpeg" + + def test_normalize_raises_for_nonexistent_str_path(self): + """Verify ValidationError is raised for a string path that doesn't exist.""" + with pytest.raises(ValidationError, match="File not found"): + _normalize_file("/nonexistent/path/image.png") + + def test_normalize_raises_for_nonexistent_path_object(self): + """Verify ValidationError is raised for a Path that doesn't exist.""" + with pytest.raises(ValidationError, match="File not found"): + _normalize_file(Path("/nonexistent/path/image.png")) + + def test_normalize_raises_for_bytes_without_filename(self): + """Verify ValidationError is raised for bytes input without filename.""" + with pytest.raises(ValidationError, match="filename is required"): + _normalize_file(b"data") + + def test_normalize_infers_mime_type_from_extension(self, tmp_path): + """Verify MIME type is correctly inferred from the file extension.""" + for ext, expected_mime in [ + ("jpg", "image/jpeg"), + ("png", "image/png"), + ("mp4", "video/mp4"), + ("pdf", "application/pdf"), + ("txt", "text/plain"), + ]: + test_file = tmp_path / f"file.{ext}" + test_file.write_bytes(b"data") + _, _, mime_type = _normalize_file(test_file) + assert mime_type == expected_mime, f"Expected {expected_mime} for .{ext}" + + def test_normalize_uses_octet_stream_for_unknown_extension(self, tmp_path): + """Verify application/octet-stream is used for unknown file extensions.""" + test_file = tmp_path / "file.xyz_unknown" + test_file.write_bytes(b"data") + + _, _, mime_type = _normalize_file(test_file) + + assert mime_type == "application/octet-stream" diff --git a/ts/src/client.test.ts b/ts/src/client.test.ts index e1e50fb..fb706a0 100644 --- a/ts/src/client.test.ts +++ b/ts/src/client.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { Capture } from './client.js' +import { AuthenticationError, NotFoundError, NetworkError } from './errors.js' // Test asset NID const TEST_NID = 'bafybeif3mhxhkhfwuszl2lybtai3hz3q6naqpfisd4q55mcc7opkmiv5ei' @@ -337,3 +338,863 @@ describe('Asset Search Validation', () => { ).rejects.toThrow('sampleCount must be a positive integer') }) }) + +// Shared mock asset API response +const MOCK_ASSET_RESPONSE = { + id: TEST_NID, + asset_file_name: 'test.png', + asset_file_mime_type: 'image/png', + caption: 'Test caption', + headline: 'Test headline', +} + +describe('register()', () => { + let originalFetch: typeof global.fetch + + beforeEach(() => { + originalFetch = global.fetch + }) + + afterEach(() => { + global.fetch = originalFetch + vi.restoreAllMocks() + }) + + it('should POST to assets endpoint with multipart form data', async () => { + const capture = new Capture({ token: 'test-token' }) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => MOCK_ASSET_RESPONSE, + } as Response) + global.fetch = mockFetch + + const fileData = new Uint8Array([1, 2, 3, 4]) + await capture.register(fileData, { filename: 'test.png' }) + + expect(mockFetch).toHaveBeenCalledTimes(1) + const [url, options] = mockFetch.mock.calls[0] + expect(url).toBe('https://api.numbersprotocol.io/api/v3/assets/') + expect(options?.method).toBe('POST') + + const headers = options?.headers as Record + expect(headers.Authorization).toBe('token test-token') + + const formData = options?.body as FormData + expect(formData.has('asset_file')).toBe(true) + }) + + it('should include caption and headline when provided', async () => { + const capture = new Capture({ token: 'test-token' }) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => MOCK_ASSET_RESPONSE, + } as Response) + global.fetch = mockFetch + + const fileData = new Uint8Array([1, 2, 3, 4]) + await capture.register(fileData, { + filename: 'test.png', + caption: 'My caption', + headline: 'My title', + }) + + const [, options] = mockFetch.mock.calls[0] + const formData = options?.body as FormData + expect(formData.get('caption')).toBe('My caption') + expect(formData.get('headline')).toBe('My title') + }) + + it('should set public_access to true by default', async () => { + const capture = new Capture({ token: 'test-token' }) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => MOCK_ASSET_RESPONSE, + } as Response) + global.fetch = mockFetch + + const fileData = new Uint8Array([1, 2, 3, 4]) + await capture.register(fileData, { filename: 'test.png' }) + + const [, options] = mockFetch.mock.calls[0] + const formData = options?.body as FormData + expect(formData.get('public_access')).toBe('true') + }) + + it('should parse asset response correctly', async () => { + const capture = new Capture({ token: 'test-token' }) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => MOCK_ASSET_RESPONSE, + } as Response) + global.fetch = mockFetch + + const fileData = new Uint8Array([1, 2, 3, 4]) + const asset = await capture.register(fileData, { filename: 'test.png' }) + + expect(asset.nid).toBe(TEST_NID) + expect(asset.filename).toBe('test.png') + expect(asset.mimeType).toBe('image/png') + expect(asset.caption).toBe('Test caption') + expect(asset.headline).toBe('Test headline') + }) + + it('should throw ValidationError for headline over 25 characters', async () => { + const capture = new Capture({ token: 'test-token' }) + const fileData = new Uint8Array([1, 2, 3, 4]) + + await expect( + capture.register(fileData, { filename: 'test.png', headline: 'a'.repeat(26) }) + ).rejects.toThrow('headline must be 25 characters or less') + }) + + it('should throw ValidationError for empty file', async () => { + const capture = new Capture({ token: 'test-token' }) + const fileData = new Uint8Array([]) + + await expect( + capture.register(fileData, { filename: 'test.png' }) + ).rejects.toThrow('file cannot be empty') + }) + + it('should throw ValidationError for Buffer input without filename', async () => { + const capture = new Capture({ token: 'test-token' }) + const fileData = new Uint8Array([1, 2, 3]) + + await expect(capture.register(fileData)).rejects.toThrow('filename is required') + }) + + it('should throw AuthenticationError on 401 response', async () => { + const capture = new Capture({ token: 'bad-token' }) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + json: async () => ({ detail: 'Unauthorized' }), + } as Response) + global.fetch = mockFetch + + const fileData = new Uint8Array([1, 2, 3, 4]) + await expect(capture.register(fileData, { filename: 'test.png' })).rejects.toThrow( + AuthenticationError + ) + }) + + it('should include Blob input with correct filename', async () => { + const capture = new Capture({ token: 'test-token' }) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => MOCK_ASSET_RESPONSE, + } as Response) + global.fetch = mockFetch + + const blob = new Blob([new Uint8Array([1, 2, 3, 4])], { type: 'image/png' }) + await capture.register(blob, { filename: 'photo.png' }) + + const [, options] = mockFetch.mock.calls[0] + const formData = options?.body as FormData + expect(formData.has('asset_file')).toBe(true) + }) +}) + +describe('update()', () => { + let originalFetch: typeof global.fetch + + beforeEach(() => { + originalFetch = global.fetch + }) + + afterEach(() => { + global.fetch = originalFetch + vi.restoreAllMocks() + }) + + it('should PATCH to correct asset endpoint', async () => { + const capture = new Capture({ token: 'test-token' }) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => MOCK_ASSET_RESPONSE, + } as Response) + global.fetch = mockFetch + + await capture.update(TEST_NID, { caption: 'Updated caption' }) + + expect(mockFetch).toHaveBeenCalledTimes(1) + const [url, options] = mockFetch.mock.calls[0] + expect(url).toBe(`https://api.numbersprotocol.io/api/v3/assets/${TEST_NID}/`) + expect(options?.method).toBe('PATCH') + }) + + it('should send authorization header', async () => { + const capture = new Capture({ token: 'secret-token' }) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => MOCK_ASSET_RESPONSE, + } as Response) + global.fetch = mockFetch + + await capture.update(TEST_NID, { caption: 'Updated' }) + + const [, options] = mockFetch.mock.calls[0] + const headers = options?.headers as Record + expect(headers.Authorization).toBe('token secret-token') + }) + + it('should include caption in form data', async () => { + const capture = new Capture({ token: 'test-token' }) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => MOCK_ASSET_RESPONSE, + } as Response) + global.fetch = mockFetch + + await capture.update(TEST_NID, { caption: 'New caption' }) + + const [, options] = mockFetch.mock.calls[0] + const formData = options?.body as FormData + expect(formData.get('caption')).toBe('New caption') + }) + + it('should include headline and commit_message in form data', async () => { + const capture = new Capture({ token: 'test-token' }) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => MOCK_ASSET_RESPONSE, + } as Response) + global.fetch = mockFetch + + await capture.update(TEST_NID, { + headline: 'New title', + commitMessage: 'Updated headline', + }) + + const [, options] = mockFetch.mock.calls[0] + const formData = options?.body as FormData + expect(formData.get('headline')).toBe('New title') + expect(formData.get('commit_message')).toBe('Updated headline') + }) + + it('should serialize customMetadata as JSON', async () => { + const capture = new Capture({ token: 'test-token' }) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => MOCK_ASSET_RESPONSE, + } as Response) + global.fetch = mockFetch + + await capture.update(TEST_NID, { + customMetadata: { source: 'camera', location: 'Taiwan' }, + }) + + const [, options] = mockFetch.mock.calls[0] + const formData = options?.body as FormData + const metadata = JSON.parse(formData.get('nit_commit_custom') as string) + expect(metadata.source).toBe('camera') + expect(metadata.location).toBe('Taiwan') + }) + + it('should parse updated asset response', async () => { + const capture = new Capture({ token: 'test-token' }) + + const updatedResponse = { ...MOCK_ASSET_RESPONSE, caption: 'Updated caption' } + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => updatedResponse, + } as Response) + global.fetch = mockFetch + + const asset = await capture.update(TEST_NID, { caption: 'Updated caption' }) + + expect(asset.nid).toBe(TEST_NID) + expect(asset.caption).toBe('Updated caption') + }) + + it('should throw ValidationError for empty nid', async () => { + const capture = new Capture({ token: 'test-token' }) + + await expect(capture.update('', { caption: 'test' })).rejects.toThrow('nid is required') + }) + + it('should throw ValidationError for headline over 25 characters', async () => { + const capture = new Capture({ token: 'test-token' }) + + await expect( + capture.update(TEST_NID, { headline: 'a'.repeat(26) }) + ).rejects.toThrow('headline must be 25 characters or less') + }) + + it('should throw NotFoundError on 404 response', async () => { + const capture = new Capture({ token: 'test-token' }) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + json: async () => ({ detail: 'Not found' }), + } as Response) + global.fetch = mockFetch + + await expect(capture.update(TEST_NID, { caption: 'test' })).rejects.toThrow(NotFoundError) + }) +}) + +describe('get()', () => { + let originalFetch: typeof global.fetch + + beforeEach(() => { + originalFetch = global.fetch + }) + + afterEach(() => { + global.fetch = originalFetch + vi.restoreAllMocks() + }) + + it('should GET from correct asset endpoint', async () => { + const capture = new Capture({ token: 'test-token' }) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => MOCK_ASSET_RESPONSE, + } as Response) + global.fetch = mockFetch + + await capture.get(TEST_NID) + + expect(mockFetch).toHaveBeenCalledTimes(1) + const [url, options] = mockFetch.mock.calls[0] + expect(url).toBe(`https://api.numbersprotocol.io/api/v3/assets/${TEST_NID}/`) + expect(options?.method).toBe('GET') + }) + + it('should send authorization header', async () => { + const capture = new Capture({ token: 'my-token' }) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => MOCK_ASSET_RESPONSE, + } as Response) + global.fetch = mockFetch + + await capture.get(TEST_NID) + + const [, options] = mockFetch.mock.calls[0] + const headers = options?.headers as Record + expect(headers.Authorization).toBe('token my-token') + }) + + it('should parse asset response correctly', async () => { + const capture = new Capture({ token: 'test-token' }) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => MOCK_ASSET_RESPONSE, + } as Response) + global.fetch = mockFetch + + const asset = await capture.get(TEST_NID) + + expect(asset.nid).toBe(TEST_NID) + expect(asset.filename).toBe('test.png') + expect(asset.mimeType).toBe('image/png') + expect(asset.caption).toBe('Test caption') + expect(asset.headline).toBe('Test headline') + }) + + it('should throw ValidationError for empty nid', async () => { + const capture = new Capture({ token: 'test-token' }) + + await expect(capture.get('')).rejects.toThrow('nid is required') + }) + + it('should throw NotFoundError on 404 response', async () => { + const capture = new Capture({ token: 'test-token' }) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + json: async () => ({ detail: 'Not found' }), + } as Response) + global.fetch = mockFetch + + await expect(capture.get(TEST_NID)).rejects.toThrow(NotFoundError) + }) + + it('should use custom base URL when configured', async () => { + const capture = new Capture({ token: 'test-token', baseUrl: 'https://custom.api.com/v1' }) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => MOCK_ASSET_RESPONSE, + } as Response) + global.fetch = mockFetch + + await capture.get(TEST_NID) + + const [url] = mockFetch.mock.calls[0] + expect(url).toBe(`https://custom.api.com/v1/assets/${TEST_NID}/`) + }) +}) + +// Mock API URLs for history/tree tests +const HISTORY_API_URL = + 'https://e23hi68y55.execute-api.us-east-1.amazonaws.com/default/get-commits-storage-backend-jade-near' +const MERGE_TREE_API_URL = + 'https://us-central1-numbers-protocol-api.cloudfunctions.net/get-full-asset-tree' +const NFT_SEARCH_API_URL = 'https://eofveg1f59hrbn.m.pipedream.net' + +const MOCK_HISTORY_RESPONSE = { + nid: TEST_NID, + commits: [ + { + assetTreeCid: 'bafyreif123', + txHash: '0xabc123', + author: '0x1234', + committer: '0x1234', + timestampCreated: 1700000000, + action: 'create', + }, + ], +} + +describe('getHistory()', () => { + let originalFetch: typeof global.fetch + + beforeEach(() => { + originalFetch = global.fetch + }) + + afterEach(() => { + global.fetch = originalFetch + vi.restoreAllMocks() + }) + + it('should GET history with nid query param', async () => { + const capture = new Capture({ token: 'test-token' }) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => MOCK_HISTORY_RESPONSE, + } as Response) + global.fetch = mockFetch + + await capture.getHistory(TEST_NID) + + expect(mockFetch).toHaveBeenCalledTimes(1) + const [url] = mockFetch.mock.calls[0] + expect(url.toString()).toContain(HISTORY_API_URL) + expect(url.toString()).toContain(`nid=${TEST_NID}`) + }) + + it('should include testnet query param when testnet is true', async () => { + const capture = new Capture({ token: 'test-token', testnet: true }) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => MOCK_HISTORY_RESPONSE, + } as Response) + global.fetch = mockFetch + + await capture.getHistory(TEST_NID) + + const [url] = mockFetch.mock.calls[0] + expect(url.toString()).toContain('testnet=true') + }) + + it('should NOT include testnet param when testnet is false', async () => { + const capture = new Capture({ token: 'test-token', testnet: false }) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => MOCK_HISTORY_RESPONSE, + } as Response) + global.fetch = mockFetch + + await capture.getHistory(TEST_NID) + + const [url] = mockFetch.mock.calls[0] + expect(url.toString()).not.toContain('testnet') + }) + + it('should send authorization header', async () => { + const capture = new Capture({ token: 'history-token' }) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => MOCK_HISTORY_RESPONSE, + } as Response) + global.fetch = mockFetch + + await capture.getHistory(TEST_NID) + + const [, options] = mockFetch.mock.calls[0] + const headers = options?.headers as Record + expect(headers.Authorization).toBe('token history-token') + }) + + it('should parse commits from response', async () => { + const capture = new Capture({ token: 'test-token' }) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => MOCK_HISTORY_RESPONSE, + } as Response) + global.fetch = mockFetch + + const commits = await capture.getHistory(TEST_NID) + + expect(commits).toHaveLength(1) + expect(commits[0].assetTreeCid).toBe('bafyreif123') + expect(commits[0].txHash).toBe('0xabc123') + expect(commits[0].author).toBe('0x1234') + expect(commits[0].timestamp).toBe(1700000000) + expect(commits[0].action).toBe('create') + }) + + it('should throw ValidationError for empty nid', async () => { + const capture = new Capture({ token: 'test-token' }) + + await expect(capture.getHistory('')).rejects.toThrow('nid is required') + }) + + it('should throw error on non-ok history response', async () => { + const capture = new Capture({ token: 'test-token' }) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + json: async () => ({}), + } as Response) + global.fetch = mockFetch + + await expect(capture.getHistory(TEST_NID)).rejects.toThrow(NetworkError) + }) +}) + +describe('getAssetTree()', () => { + let originalFetch: typeof global.fetch + + beforeEach(() => { + originalFetch = global.fetch + }) + + afterEach(() => { + global.fetch = originalFetch + vi.restoreAllMocks() + }) + + const MOCK_MERGE_RESPONSE = { + mergedAssetTree: { + assetCid: 'bafybei123', + assetSha256: 'abc123', + creatorName: 'Test Creator', + creatorWallet: '0x1234', + createdAt: 1700000000, + caption: 'Test caption', + mimeType: 'image/png', + }, + assetTrees: [], + } + + it('should call history API then merge API', async () => { + const capture = new Capture({ token: 'test-token' }) + + const mockFetch = vi.fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => MOCK_HISTORY_RESPONSE, + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => MOCK_MERGE_RESPONSE, + } as Response) + global.fetch = mockFetch + + await capture.getAssetTree(TEST_NID) + + expect(mockFetch).toHaveBeenCalledTimes(2) + const [firstUrl] = mockFetch.mock.calls[0] + const [secondUrl] = mockFetch.mock.calls[1] + expect(firstUrl.toString()).toContain(HISTORY_API_URL) + expect(secondUrl.toString()).toBe(MERGE_TREE_API_URL) + }) + + it('should POST commit data to merge endpoint', async () => { + const capture = new Capture({ token: 'test-token' }) + + const mockFetch = vi.fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => MOCK_HISTORY_RESPONSE, + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => MOCK_MERGE_RESPONSE, + } as Response) + global.fetch = mockFetch + + await capture.getAssetTree(TEST_NID) + + const [, mergeOptions] = mockFetch.mock.calls[1] + expect(mergeOptions?.method).toBe('POST') + const body = JSON.parse(mergeOptions?.body as string) + expect(body).toHaveLength(1) + expect(body[0].assetTreeCid).toBe('bafyreif123') + expect(body[0].timestampCreated).toBe(1700000000) + }) + + it('should parse merged asset tree response', async () => { + const capture = new Capture({ token: 'test-token' }) + + const mockFetch = vi.fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => MOCK_HISTORY_RESPONSE, + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => MOCK_MERGE_RESPONSE, + } as Response) + global.fetch = mockFetch + + const tree = await capture.getAssetTree(TEST_NID) + + expect(tree.assetCid).toBe('bafybei123') + expect(tree.assetSha256).toBe('abc123') + expect(tree.creatorName).toBe('Test Creator') + expect(tree.creatorWallet).toBe('0x1234') + expect(tree.caption).toBe('Test caption') + expect(tree.mimeType).toBe('image/png') + }) + + it('should throw CaptureError when no commits found', async () => { + const capture = new Capture({ token: 'test-token' }) + + const emptyHistoryResponse = { nid: TEST_NID, commits: [] } + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => emptyHistoryResponse, + } as Response) + global.fetch = mockFetch + + await expect(capture.getAssetTree(TEST_NID)).rejects.toThrow('No commits found for asset') + }) + + it('should throw ValidationError for empty nid', async () => { + const capture = new Capture({ token: 'test-token' }) + + await expect(capture.getAssetTree('')).rejects.toThrow('nid is required') + }) +}) + +describe('searchNft()', () => { + let originalFetch: typeof global.fetch + + beforeEach(() => { + originalFetch = global.fetch + }) + + afterEach(() => { + global.fetch = originalFetch + vi.restoreAllMocks() + }) + + const MOCK_NFT_RESPONSE = { + records: [ + { + token_id: '42', + contract: '0xContract', + network: 'ethereum', + owner: '0xOwner', + }, + ], + order_id: 'nft-order-123', + } + + it('should POST to NFT search endpoint with nid in JSON body', async () => { + const capture = new Capture({ token: 'test-token' }) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => MOCK_NFT_RESPONSE, + } as Response) + global.fetch = mockFetch + + await capture.searchNft(TEST_NID) + + expect(mockFetch).toHaveBeenCalledTimes(1) + const [url, options] = mockFetch.mock.calls[0] + expect(url.toString()).toBe(NFT_SEARCH_API_URL) + expect(options?.method).toBe('POST') + + const body = JSON.parse(options?.body as string) + expect(body.nid).toBe(TEST_NID) + }) + + it('should send authorization header', async () => { + const capture = new Capture({ token: 'nft-token' }) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => MOCK_NFT_RESPONSE, + } as Response) + global.fetch = mockFetch + + await capture.searchNft(TEST_NID) + + const [, options] = mockFetch.mock.calls[0] + const headers = options?.headers as Record + expect(headers.Authorization).toBe('token nft-token') + }) + + it('should send Content-Type application/json header', async () => { + const capture = new Capture({ token: 'test-token' }) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => MOCK_NFT_RESPONSE, + } as Response) + global.fetch = mockFetch + + await capture.searchNft(TEST_NID) + + const [, options] = mockFetch.mock.calls[0] + const headers = options?.headers as Record + expect(headers['Content-Type']).toBe('application/json') + }) + + it('should parse NFT records from response', async () => { + const capture = new Capture({ token: 'test-token' }) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => MOCK_NFT_RESPONSE, + } as Response) + global.fetch = mockFetch + + const result = await capture.searchNft(TEST_NID) + + expect(result.records).toHaveLength(1) + expect(result.records[0].tokenId).toBe('42') + expect(result.records[0].contract).toBe('0xContract') + expect(result.records[0].network).toBe('ethereum') + expect(result.records[0].owner).toBe('0xOwner') + expect(result.orderId).toBe('nft-order-123') + }) + + it('should handle empty NFT records', async () => { + const capture = new Capture({ token: 'test-token' }) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ records: [], order_id: 'empty-order' }), + } as Response) + global.fetch = mockFetch + + const result = await capture.searchNft(TEST_NID) + + expect(result.records).toHaveLength(0) + expect(result.orderId).toBe('empty-order') + }) + + it('should throw ValidationError for empty nid', async () => { + const capture = new Capture({ token: 'test-token' }) + + await expect(capture.searchNft('')).rejects.toThrow('nid is required for NFT search') + }) + + it('should throw error on non-ok NFT search response', async () => { + const capture = new Capture({ token: 'test-token' }) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + json: async () => ({ message: 'Unauthorized' }), + } as Response) + global.fetch = mockFetch + + await expect(capture.searchNft(TEST_NID)).rejects.toThrow(AuthenticationError) + }) +}) + +describe('normalizeFile() via register()', () => { + let originalFetch: typeof global.fetch + + beforeEach(() => { + originalFetch = global.fetch + }) + + afterEach(() => { + global.fetch = originalFetch + vi.restoreAllMocks() + }) + + it('should handle Uint8Array input with provided filename', async () => { + const capture = new Capture({ token: 'test-token' }) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ ...MOCK_ASSET_RESPONSE, asset_file_name: 'data.json' }), + } as Response) + global.fetch = mockFetch + + const data = new Uint8Array([123, 34, 107, 34, 58, 49, 125]) // {"k":1} + await capture.register(data, { filename: 'data.json' }) + + const [, options] = mockFetch.mock.calls[0] + const formData = options?.body as FormData + const fileBlob = formData.get('asset_file') as File + expect(fileBlob).toBeTruthy() + expect(fileBlob.type).toBe('application/json') + }) + + it('should infer MIME type from filename extension', async () => { + const capture = new Capture({ token: 'test-token' }) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => MOCK_ASSET_RESPONSE, + } as Response) + global.fetch = mockFetch + + const data = new Uint8Array([1, 2, 3, 4]) + await capture.register(data, { filename: 'photo.jpg' }) + + const [, options] = mockFetch.mock.calls[0] + const formData = options?.body as FormData + const fileBlob = formData.get('asset_file') as File + expect(fileBlob.type).toBe('image/jpeg') + }) + + it('should handle Blob input with provided filename', async () => { + const capture = new Capture({ token: 'test-token' }) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => MOCK_ASSET_RESPONSE, + } as Response) + global.fetch = mockFetch + + const blob = new Blob(['hello world'], { type: 'text/plain' }) + await capture.register(blob, { filename: 'readme.txt' }) + + const [, options] = mockFetch.mock.calls[0] + const formData = options?.body as FormData + expect(formData.has('asset_file')).toBe(true) + }) + + it('should throw ValidationError for Blob without filename', async () => { + const capture = new Capture({ token: 'test-token' }) + + const blob = new Blob(['hello world'], { type: 'text/plain' }) + await expect(capture.register(blob)).rejects.toThrow('filename is required for Blob input') + }) +})