diff --git a/notecard/note.py b/notecard/note.py index 2716687..a4da5a7 100644 --- a/notecard/note.py +++ b/notecard/note.py @@ -9,12 +9,11 @@ # 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: @@ -22,6 +21,7 @@ def add(card, file=None, body=None, payload=None, sync=None, port=None): file (string): The name of the file. body (JSON object): A developer-defined tracker ID. payload (string): An optional base64-encoded string. + binary (bytearray): Binary data to be stored in the note. sync (bool): Perform an immediate sync after adding. port (int): If provided, a unique number to represent a notefile. Required for Notecard LoRa. @@ -29,6 +29,8 @@ def add(card, file=None, body=None, payload=None, sync=None, port=None): Returns: string: The result of the Notecard request. """ + from notecard import binary_helpers + req = {"req": "note.add"} if file: req["file"] = file @@ -40,6 +42,17 @@ 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: + if not isinstance(binary, bytearray): + return {"err": "Binary data must be a bytearray"} + try: + binary_helpers.binary_store_reset(card) + binary_helpers.binary_store_transmit(card, binary, 0) + req["binary"] = True + except Exception as e: + return {"err": f"Failed to store binary data: {str(e)}"} + return card.Transaction(req) @@ -93,8 +106,11 @@ 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 the note contains binary data, + the 'binary' field in the response will contain the binary data as a bytearray. """ + from notecard import binary_helpers + req = {"req": "note.get"} req["file"] = file if note_id: @@ -103,6 +119,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 +171,80 @@ 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, 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. + values as "hints" for the data type. Supported types are: + boolean, integer, float, and string. 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 set to "compact", tells the Notecard to omit + additional metadata to save on 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: + for key, value in body.items(): + if not isinstance(value, (bool, int, float, str)): + return { + "err": ( + f"Field '{key}' has unsupported type. " + "Must be boolean, integer, float, or string.") + } + if isinstance(value, float) and value.is_integer(): + body[key] = int(value) req["body"] = body - if length: + + if verify is not None: + if not isinstance(verify, bool): + return {"err": "verify parameter must be a boolean"} + + if length is not None: + if not isinstance(length, int) or length < 0: + return {"err": "Length must be a non-negative integer"} req["length"] = length - if port: + + if port is not None: + if not isinstance(port, int) or not (1 <= port <= 100): + return {"err": "Port must be an integer between 1 and 100"} req["port"] = port - if compact: + + if compact is True: + format = "compact" + + if format == "compact": req["format"] = "compact" + if body: + allowed_metadata = {"_time", "_lat", "_lon", "_loc"} + for key in body.keys(): + if key.startswith("_") and key not in allowed_metadata: + return { + "err": ( + f"Field '{key}' is not allowed in compact mode. " + f"Only {allowed_metadata} are allowed.") + } + + 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/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..d79b623 --- /dev/null +++ b/test/fluent_api/test_note_template.py @@ -0,0 +1,160 @@ +"""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_invalid_type(mock_card): + body = {"invalid_field": {"nested": "object"}} + result = note.template(mock_card, body=body) + assert "err" in result + assert "invalid_field" in result["err"] + assert not mock_card.Transaction.called + + +def test_template_invalid_length(mock_card): + result = note.template(mock_card, length=-1) + assert "err" in result + assert "Length" in result["err"] + assert not mock_card.Transaction.called + + +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_invalid_port(mock_card): + result = note.template(mock_card, port=101) + assert "err" in result + assert "Port" in result["err"] + assert not mock_card.Transaction.called + + +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_allowed_metadata(mock_card): + body = { + "field": "value", + "_time": "2023-01-01", + "_lat": 12.34, + "_lon": 56.78, + "_loc": "NYC" + } + note.template(mock_card, body=body, format="compact") + assert mock_card.Transaction.called + assert mock_card.Transaction.call_args[0][0]["body"] == body + + +def test_template_compact_with_invalid_metadata(mock_card): + body = { + "field": "value", + "_invalid": "not allowed" + } + result = note.template(mock_card, body=body, format="compact") + assert "err" in result + assert "_invalid" in result["err"] + assert not mock_card.Transaction.called + + +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_verify_invalid_type(mock_card): + result = note.template(mock_card, verify="yes") + assert "err" in result + assert "verify parameter must be a boolean" in result["err"] + assert not mock_card.Transaction.called + + +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