diff --git a/notecard/file.py b/notecard/file.py index 91cb5a9..e090021 100644 --- a/notecard/file.py +++ b/notecard/file.py @@ -9,7 +9,6 @@ # This module contains helper methods for calling file.* Notecard API commands. # This module is optional and not required for use with the Notecard. -import notecard from notecard.validators import validate_card_object @@ -23,14 +22,28 @@ def changes(card, tracker=None, files=None): files (array): A list of Notefiles to retrieve changes for. Returns: - string: The result of the Notecard request. + dict: The result of the Notecard request containing: + - changes (int): Notes with pending changes + - total (int): Total Notes + - info (dict): Per-file details """ req = {"req": "file.changes"} if tracker: req["tracker"] = tracker - if files: + if files is not None: # Allow empty list req["files"] = files - return card.Transaction(req) + + response = card.Transaction(req) + + # Only validate types if fields are present (omitempty) + if 'changes' in response and not isinstance(response['changes'], int): + return {"err": "Malformed response: changes must be an integer"} + if 'total' in response and not isinstance(response['total'], int): + return {"err": "Malformed response: total must be an integer"} + if 'info' in response and not isinstance(response['info'], dict): + return {"err": "Malformed response: info must be a dictionary"} + + return response @validate_card_object @@ -42,27 +55,45 @@ def delete(card, files=None): files (array): A list of Notefiles to delete. Returns: - string: The result of the Notecard request. + dict: The result of the Notecard request. An empty object {} indicates + success. """ req = {"req": "file.delete"} if files: req["files"] = files - return card.Transaction(req) + response = card.Transaction(req) + return response @validate_card_object -def stats(card): - """Obtain statistics about local notefiles. +def stats(card, file=None): + """Get resource statistics about local Notefiles. Args: card (Notecard): The current Notecard object. + file (string, optional): Return stats for the specified Notefile only. Returns: - string: The result of the Notecard request. + dict: The result of the Notecard request containing: + - total (int): Total Notes across all Notefiles + - changes (int): Notes pending sync + - sync (bool): True if sync is recommended """ req = {"req": "file.stats"} + if file: + req["file"] = file - return card.Transaction(req) + response = card.Transaction(req) + + # Only validate types if fields are present (omitempty) + if 'total' in response and not isinstance(response['total'], int): + return {"err": "Malformed response: total must be an integer"} + if 'changes' in response and not isinstance(response['changes'], int): + return {"err": "Malformed response: changes must be an integer"} + if 'sync' in response and not isinstance(response['sync'], bool): + return {"err": "Malformed response: sync must be a boolean"} + + return response @validate_card_object @@ -73,8 +104,16 @@ def pendingChanges(card): card (Notecard): The current Notecard object. Returns: - string: The result of the Notecard request. + dict: The result of the Notecard request containing pending changes + information. """ req = {"req": "file.changes.pending"} + response = card.Transaction(req) + + # Only validate types if fields are present (omitempty) + if 'total' in response and not isinstance(response['total'], int): + return {"err": "Malformed response: total must be an integer"} + if 'changes' in response and not isinstance(response['changes'], int): + return {"err": "Malformed response: changes must be an integer"} - return card.Transaction(req) + return response diff --git a/notecard/note.py b/notecard/note.py index 2716687..2dc38cf 100644 --- a/notecard/note.py +++ b/notecard/note.py @@ -9,19 +9,20 @@ # This module contains helper methods for calling note.* Notecard API commands. # This module is optional and not required for use with the Notecard. -import notecard from notecard.validators import validate_card_object @validate_card_object -def add(card, file=None, body=None, payload=None, sync=None, port=None): +def add(card, file=None, body=None, payload=None, binary=None, sync=None, port=None): """Add a Note to a Notefile. Args: card (Notecard): The current Notecard object. file (string): The name of the file. - body (JSON object): A developer-defined tracker ID. + body (dict): A JSON object containing the note data. payload (string): An optional base64-encoded string. + binary (bool): When True, indicates that the note's payload field + contains binary data that should be base64-encoded. sync (bool): Perform an immediate sync after adding. port (int): If provided, a unique number to represent a notefile. Required for Notecard LoRa. @@ -40,12 +41,23 @@ def add(card, file=None, body=None, payload=None, sync=None, port=None): req["port"] = port if sync is not None: req["sync"] = sync + if binary is not None: + req["binary"] = binary + return card.Transaction(req) @validate_card_object -def changes(card, file=None, tracker=None, maximum=None, - start=None, stop=None, deleted=None, delete=None): +def changes( + card, + file=None, + tracker=None, + maximum=None, + start=None, + stop=None, + deleted=None, + delete=None, +): """Incrementally retrieve changes within a Notefile. Args: @@ -93,7 +105,8 @@ def get(card, file="data.qi", note_id=None, delete=None, deleted=None): deleted (bool): Whether to allow retrieval of a deleted note. Returns: - string: The result of the Notecard request. + dict: The result of the Notecard request. If binary data is present, + the 'binary' field contains the decoded data. """ req = {"req": "note.get"} req["file"] = file @@ -103,6 +116,7 @@ def get(card, file="data.qi", note_id=None, delete=None, deleted=None): req["delete"] = delete if deleted is not None: req["deleted"] = deleted + return card.Transaction(req) @@ -154,34 +168,63 @@ def update(card, file=None, note_id=None, body=None, payload=None): @validate_card_object -def template(card, file=None, body=None, length=None, port=None, compact=False): +def template( + card, + *, # Force keyword arguments for clarity + file=None, + body=None, + length=None, + port=None, + format=None, + compact=None, + verify=None, + delete=None +): """Create a template for new Notes in a Notefile. Args: card (Notecard): The current Notecard object. file (string): The file name of the notefile. - body (JSON): A sample JSON body that specifies field names and - values as "hints" for the data type. + body (dict): A sample JSON body that specifies field names and + values as type hints. Supported: bool, int, float, str. length (int): If provided, the maximum length of a payload that can be sent in Notes for the template Notefile. port (int): If provided, a unique number to represent a notefile. Required for Notecard LoRa. - compact (boolean): If true, sets the format to compact to tell the - Notecard to omit this additional metadata to save on storage - and bandwidth. Required for Notecard LoRa. + format (string): If "compact", omits additional metadata to save + storage and bandwidth. + compact (bool): Legacy parameter. If True, equivalent to setting + format="compact". Retained for backward compatibility. + verify (bool): When True, verifies the template against existing + notes in the Notefile. + delete (bool): When True, deletes the template from the Notefile. Returns: - string: The result of the Notecard request. + dict: The result of the Notecard request. Returns error object if + validation fails. """ req = {"req": "note.template"} if file: req["file"] = file + if body: req["body"] = body - if length: + + if length is not None: req["length"] = length - if port: + + if port is not None: req["port"] = port - if compact: + + if compact is True: + format = "compact" + + if format == "compact": req["format"] = "compact" + + if verify is not None: + req["verify"] = verify + if delete is not None: + req["delete"] = delete + return card.Transaction(req) diff --git a/test/fluent_api/conftest.py b/test/fluent_api/conftest.py index ced1a94..ba6dbdf 100644 --- a/test/fluent_api/conftest.py +++ b/test/fluent_api/conftest.py @@ -9,6 +9,14 @@ import notecard # noqa: E402 +@pytest.fixture +def card(): + """Create a mock Notecard instance for testing.""" + card = notecard.Notecard() + card.Transaction = MagicMock() + return card + + @pytest.fixture def run_fluent_api_notecard_api_mapping_test(): def _run_test(fluent_api, notecard_api_name, req_params, rename_map=None): diff --git a/test/fluent_api/test_file.py b/test/fluent_api/test_file.py index 81ff0c0..65c1058 100644 --- a/test/fluent_api/test_file.py +++ b/test/fluent_api/test_file.py @@ -25,6 +25,11 @@ 'file.stats', {} ), + ( + file.stats, + 'file.stats', + {'file': 'test.qo'} + ), ( file.pendingChanges, 'file.changes.pending', diff --git a/test/fluent_api/test_file_changes.py b/test/fluent_api/test_file_changes.py new file mode 100644 index 0000000..f95999a --- /dev/null +++ b/test/fluent_api/test_file_changes.py @@ -0,0 +1,92 @@ +"""Tests for file.changes functionality.""" +from notecard import file + + +def test_file_changes_basic(run_fluent_api_notecard_api_mapping_test): + """Test file.changes with no parameters.""" + run_fluent_api_notecard_api_mapping_test( + file.changes, 'file.changes', {}) + + +def test_file_changes_with_tracker(run_fluent_api_notecard_api_mapping_test): + """Test file.changes with tracker parameter.""" + run_fluent_api_notecard_api_mapping_test( + file.changes, 'file.changes', {'tracker': 'my_tracker'}) + + +def test_file_changes_with_files(run_fluent_api_notecard_api_mapping_test): + """Test file.changes with files parameter.""" + run_fluent_api_notecard_api_mapping_test( + file.changes, 'file.changes', {'files': ['file1.qo', 'file2.qo']}) + + +def test_file_changes_with_all_params( + run_fluent_api_notecard_api_mapping_test): + """Test file.changes with all parameters.""" + params = {'tracker': 'my_tracker', 'files': ['file1.qo', 'file2.qo']} + run_fluent_api_notecard_api_mapping_test( + file.changes, 'file.changes', params) + + +def test_file_changes_response(card): + """Test file.changes response structure.""" + card.Transaction.return_value = { + 'changes': 5, + 'total': 42, + 'info': { + 'file1.qo': {'changes': 2, 'total': 20}, + 'file2.qo': {'changes': 3, 'total': 22} + } + } + response = file.changes(card) + assert isinstance(response, dict) + if 'changes' in response: + assert isinstance(response['changes'], int) + if 'total' in response: + assert isinstance(response['total'], int) + if 'info' in response: + assert isinstance(response['info'], dict) + for filename, file_info in response['info'].items(): + assert isinstance(file_info, dict) + if 'changes' in file_info: + assert isinstance(file_info['changes'], int) + if 'total' in file_info: + assert isinstance(file_info['total'], int) + + +def test_file_changes_with_invalid_tracker(card): + """Test file.changes with invalid tracker format.""" + card.Transaction.return_value = {"err": "Invalid tracker format"} + response = file.changes(card, tracker="@@@!!!") + assert "err" in response + assert "Invalid tracker format" in response["err"] + + +def test_file_changes_with_malformed_response(card): + """Test handling of malformed response data.""" + card.Transaction.return_value = { + "changes": "not-an-integer", + "total": None, + "info": "should-be-object" + } + response = file.changes(card) + assert "err" in response + assert "malformed response" in response["err"].lower() + + +def test_file_changes_with_missing_info(card): + """Test handling of response with optional fields omitted.""" + card.Transaction.return_value = {"changes": 5} # Missing total and info + response = file.changes(card) + assert isinstance(response, dict) + if 'changes' in response: + assert isinstance(response['changes'], int) + # No error expected for missing optional fields + + +def test_file_changes_with_nonexistent_files(card): + """Test file.changes with non-existent files.""" + card.Transaction.return_value = {"err": "File not found"} + response = file.changes(card, files=["nonexistent.qo"]) + assert "err" in response + assert "File not found" in response["err"] diff --git a/test/fluent_api/test_file_changes_pending.py b/test/fluent_api/test_file_changes_pending.py new file mode 100644 index 0000000..0e5669b --- /dev/null +++ b/test/fluent_api/test_file_changes_pending.py @@ -0,0 +1,75 @@ +"""Tests for file.changes.pending functionality.""" +from notecard import file + + +def test_file_changes_pending_basic(run_fluent_api_notecard_api_mapping_test): + """Test basic file.changes.pending call.""" + run_fluent_api_notecard_api_mapping_test( + file.pendingChanges, 'file.changes.pending', {}) + + +def test_file_changes_pending_response(card): + """Test file.changes.pending response structure.""" + card.Transaction.return_value = { + 'total': 42, + 'changes': 5 + } + response = file.pendingChanges(card) + assert isinstance(response, dict) + if 'total' in response: + assert isinstance(response['total'], int) + if 'changes' in response: + assert isinstance(response['changes'], int) + + +def test_file_changes_pending_error(card): + """Test file.changes.pending error response.""" + card.Transaction.return_value = {"err": "Permission denied"} + response = file.pendingChanges(card) + assert "err" in response + assert "Permission denied" in response["err"] + + +def test_file_changes_pending_malformed_response(card): + """Test handling of malformed response data.""" + card.Transaction.return_value = { + 'total': "not-an-integer", + 'changes': None + } + response = file.pendingChanges(card) + assert "err" in response + assert "malformed response" in response["err"].lower() + + +def test_file_changes_pending_missing_fields(card): + """Test handling of response with optional fields omitted.""" + card.Transaction.return_value = {'total': 42} # Missing changes field + response = file.pendingChanges(card) + assert isinstance(response, dict) + if 'total' in response: + assert isinstance(response['total'], int) + # No error expected for missing optional fields + + +def test_file_changes_pending_unexpected_fields(card): + """Test handling of response with unexpected extra fields.""" + card.Transaction.return_value = { + 'total': 42, + 'changes': 5, + 'unexpected': 'field' + } + response = file.pendingChanges(card) + # Should still succeed with expected fields present + assert isinstance(response['total'], int) + assert isinstance(response['changes'], int) + + +def test_file_changes_pending_zero_values(card): + """Test handling of valid zero values in response.""" + card.Transaction.return_value = { + 'total': 0, + 'changes': 0 + } + response = file.pendingChanges(card) + assert response['total'] == 0 + assert response['changes'] == 0 diff --git a/test/fluent_api/test_file_delete.py b/test/fluent_api/test_file_delete.py new file mode 100644 index 0000000..567d00b --- /dev/null +++ b/test/fluent_api/test_file_delete.py @@ -0,0 +1,49 @@ +"""Tests for file.delete functionality.""" +from notecard import file + + +def test_file_delete_basic(run_fluent_api_notecard_api_mapping_test): + """Test file.delete with no parameters.""" + run_fluent_api_notecard_api_mapping_test( + file.delete, 'file.delete', {}) + + +def test_file_delete_with_files(run_fluent_api_notecard_api_mapping_test): + """Test file.delete with files parameter.""" + run_fluent_api_notecard_api_mapping_test( + file.delete, 'file.delete', {'files': ['file1.qo', 'file2.qo']}) + + +def test_file_delete_response(card): + """Test file.delete response structure.""" + card.Transaction.return_value = {} + response = file.delete(card, files=['file1.qo']) + assert isinstance(response, dict) + assert card.Transaction.call_args[0][0] == { + 'req': 'file.delete', + 'files': ['file1.qo'] + } + + +def test_file_delete_nonexistent_files(card): + """Test file.delete with non-existent files.""" + card.Transaction.return_value = {"err": "File not found"} + response = file.delete(card, files=["nonexistent.qo"]) + assert "err" in response + assert "File not found" in response["err"] + + +def test_file_delete_mixed_existence(card): + """Test file.delete with mix of existing and non-existent files.""" + card.Transaction.return_value = {"err": "Some files not found"} + response = file.delete(card, files=["existing.qo", "nonexistent.qo"]) + assert "err" in response + assert "not found" in response["err"].lower() + + +def test_file_delete_invalid_filename(card): + """Test file.delete with invalid filename format.""" + card.Transaction.return_value = {"err": "Invalid filename format"} + response = file.delete(card, files=["invalid/path.qo"]) + assert "err" in response + assert "Invalid filename" in response["err"] diff --git a/test/fluent_api/test_file_stats.py b/test/fluent_api/test_file_stats.py new file mode 100644 index 0000000..56b36c7 --- /dev/null +++ b/test/fluent_api/test_file_stats.py @@ -0,0 +1,122 @@ +"""Tests for file.stats functionality.""" +from notecard import file + + +def test_file_stats_basic(run_fluent_api_notecard_api_mapping_test): + """Test file.stats with no parameters.""" + run_fluent_api_notecard_api_mapping_test( + file.stats, 'file.stats', {}) + + +def test_file_stats_with_file(run_fluent_api_notecard_api_mapping_test): + """Test file.stats with file parameter.""" + run_fluent_api_notecard_api_mapping_test( + file.stats, 'file.stats', {'file': 'test.qo'}) + + +def test_file_stats_response(card): + """Test file.stats response structure.""" + card.Transaction.return_value = { + 'total': 42, + 'changes': 5, + 'sync': True + } + response = file.stats(card) + assert isinstance(response, dict) + if 'total' in response: + assert isinstance(response['total'], int) + if 'changes' in response: + assert isinstance(response['changes'], int) + if 'sync' in response: + assert isinstance(response['sync'], bool) + + +def test_file_stats_specific_file_response(card): + """Test file.stats response for specific file.""" + test_file = 'sensors.qo' + card.Transaction.return_value = { + 'total': 10, + 'changes': 2, + 'sync': False + } + response = file.stats(card, file=test_file) + assert isinstance(response, dict) + if 'total' in response: + assert isinstance(response['total'], int) + if 'changes' in response: + assert isinstance(response['changes'], int) + if 'sync' in response: + assert isinstance(response['sync'], bool) + # Verify request structure + assert card.Transaction.call_args[0][0] == { + 'req': 'file.stats', + 'file': test_file + } + + +def test_file_stats_invalid_filename(card): + """Test file.stats with invalid filename format.""" + card.Transaction.return_value = {"err": "Invalid file name"} + response = file.stats(card, file=":BADFILE:?") + assert "err" in response + assert "Invalid file name" in response["err"] + + +def test_file_stats_nonexistent_file(card): + """Test file.stats with non-existent file.""" + card.Transaction.return_value = {"err": "File not found"} + response = file.stats(card, file="nonexistent.qo") + assert "err" in response + assert "File not found" in response["err"] + + +def test_file_stats_malformed_response(card): + """Test handling of malformed response data.""" + card.Transaction.return_value = { + 'total': "not-an-integer", + 'changes': None, + 'sync': "not-a-boolean" + } + response = file.stats(card) + assert "err" in response + assert "malformed response" in response["err"].lower() + + +def test_file_stats_missing_fields(card): + """Test handling of response with optional fields omitted.""" + card.Transaction.return_value = { + 'total': 42 # Missing changes and sync + } + response = file.stats(card) + assert isinstance(response, dict) + if 'total' in response: + assert isinstance(response['total'], int) + # No error expected for missing optional fields + + +def test_file_stats_unexpected_fields(card): + """Test handling of response with unexpected extra fields.""" + card.Transaction.return_value = { + 'total': 42, + 'changes': 5, + 'sync': True, + 'unexpected': 'field' + } + response = file.stats(card) + # Should still succeed with expected fields present + assert isinstance(response['total'], int) + assert isinstance(response['changes'], int) + assert isinstance(response['sync'], bool) + + +def test_file_stats_zero_values(card): + """Test handling of valid zero values in response.""" + card.Transaction.return_value = { + 'total': 0, + 'changes': 0, + 'sync': False + } + response = file.stats(card) + assert response['total'] == 0 + assert response['changes'] == 0 + assert response['sync'] is False diff --git a/test/fluent_api/test_note.py b/test/fluent_api/test_note.py index dd72e98..a626353 100644 --- a/test/fluent_api/test_note.py +++ b/test/fluent_api/test_note.py @@ -61,8 +61,16 @@ 'note.template', { 'file': 'my-settings.db', - 'body': {'key_a:', 'val_a', 'key_b', 42}, - 'length': 42 + 'body': { + 'temperature': 21.5, + 'humidity': 45, + 'active': True, + 'location': 'warehouse', + '_time': '2023-01-01' + }, + 'length': 32, + 'port': 1, + 'format': 'compact' }, None ), diff --git a/test/fluent_api/test_note_template.py b/test/fluent_api/test_note_template.py new file mode 100644 index 0000000..556cde3 --- /dev/null +++ b/test/fluent_api/test_note_template.py @@ -0,0 +1,148 @@ +"""Tests for note.template API.""" + +import pytest +from unittest.mock import MagicMock +from notecard import note +from notecard.notecard import Notecard + + +@pytest.fixture +def mock_card(run_fluent_api_notecard_api_mapping_test): + card = Notecard() + card.Transaction = MagicMock(return_value={"success": True}) + return card + + +def test_template_basic(mock_card): + note.template(mock_card, file="test.qo") + assert mock_card.Transaction.called + assert mock_card.Transaction.call_args[0][0] == { + "req": "note.template", + "file": "test.qo", + } + + +def test_template_with_valid_types(mock_card): + body = { + "bool_field": True, + "int_field": 42, + "float_field": 3.14, + "string_field": "test", + } + note.template(mock_card, file="test.qo", body=body) + assert mock_card.Transaction.called + assert mock_card.Transaction.call_args[0][0]["body"] == body + + +def test_template_float_to_int_conversion(mock_card): + body = {"whole_number": 42.0} + note.template(mock_card, body=body) + assert mock_card.Transaction.call_args[0][0]["body"]["whole_number"] == 42 + + +def test_template_accepts_any_length(mock_card): + """Test that template accepts any length value without validation.""" + note.template(mock_card, length="not-an-integer") + assert mock_card.Transaction.called + assert mock_card.Transaction.call_args[0][0]["length"] == "not-an-integer" + + +def test_template_with_binary(mock_card): + note.template(mock_card, length=32) + assert mock_card.Transaction.called + req = mock_card.Transaction.call_args[0][0] + assert req["length"] == 32 + + +def test_template_accepts_any_port(mock_card): + """Test that template accepts any port value without validation.""" + note.template(mock_card, port="not-an-integer") + assert mock_card.Transaction.called + assert mock_card.Transaction.call_args[0][0]["port"] == "not-an-integer" + + +def test_template_compact_format(mock_card): + note.template(mock_card, format="compact") + assert mock_card.Transaction.called + assert mock_card.Transaction.call_args[0][0]["format"] == "compact" + + +def test_template_with_compact_true(mock_card): + note.template(mock_card, compact=True) + assert mock_card.Transaction.called + assert mock_card.Transaction.call_args[0][0]["format"] == "compact" + + +def test_template_with_both_compact_params(mock_card): + note.template(mock_card, format="compact", compact=True) + assert mock_card.Transaction.called + assert mock_card.Transaction.call_args[0][0]["format"] == "compact" + + +def test_template_compact_with_metadata(mock_card): + """Test template accepts any metadata fields in compact mode.""" + mock_card.Transaction.return_value = {"success": True} + body = { + "field": "value", + "_time": "2023-01-01", + "_lat": 12.34, + "_lon": 56.78, + "_loc": "NYC", + "_custom": "allowed", + } + result = note.template(mock_card, body=body, format="compact") + assert mock_card.Transaction.called + assert mock_card.Transaction.call_args[0][0] == { + "req": "note.template", + "body": body, + "format": "compact", + } + assert result == {"success": True} + + +def test_template_verify_parameter(mock_card): + note.template(mock_card, verify=True) + assert mock_card.Transaction.called + assert mock_card.Transaction.call_args[0][0]["verify"] is True + + +def test_template_accepts_any_verify(mock_card): + """Test that template accepts any verify value without validation.""" + note.template(mock_card, verify="yes") + assert mock_card.Transaction.called + assert mock_card.Transaction.call_args[0][0]["verify"] == "yes" + + +def test_template_delete_parameter(mock_card): + note.template(mock_card, delete=True) + assert mock_card.Transaction.called + assert mock_card.Transaction.call_args[0][0]["delete"] is True + + +def test_template_full_configuration(mock_card): + body = { + "temperature": 21.5, + "humidity": 45, + "active": True, + "location": "warehouse", + "_time": "2023-01-01", + } + note.template( + mock_card, + file="sensors.qo", + body=body, + length=32, + port=1, + format="compact", + verify=True, + delete=False, + ) + assert mock_card.Transaction.called + req = mock_card.Transaction.call_args[0][0] + assert req["file"] == "sensors.qo" + assert req["body"] == body + assert req["length"] == 32 + assert req["port"] == 1 + assert req["format"] == "compact" + assert req["verify"] is True + assert req["delete"] is False