From 4614f99358835fcf935dc45c4549c650bb4f9e1b Mon Sep 17 00:00:00 2001 From: narawat Date: Thu, 5 Mar 2026 20:17:36 +0700 Subject: [PATCH] update --- etc.jl | 4 +- src/natbridge.js | 674 ++++++++++++++++++++++ src/natbridge.py | 815 +++++++++++++++++++++++++++ src/natbridge_mpy.py | 673 ++++++++++++++++++++++ test/test_js_binary_receiver.js | 215 +++++++ test/test_js_binary_sender.js | 173 ++++++ test/test_js_dictionary_receiver.js | 220 ++++++++ test/test_js_dictionary_sender.js | 178 ++++++ test/test_js_mix_payloads_sender.js | 204 +++++++ test/test_js_table_receiver.js | 172 ++++++ test/test_js_table_sender.js | 179 ++++++ test/test_js_text_receiver.js | 206 +++++++ test/test_js_text_sender.js | 169 ++++++ test/test_mpy_binary_receiver.py | 185 ++++++ test/test_mpy_binary_sender.py | 163 ++++++ test/test_mpy_dictionary_receiver.py | 224 ++++++++ test/test_mpy_dictionary_sender.py | 177 ++++++ test/test_mpy_text_receiver.py | 209 +++++++ test/test_mpy_text_sender.py | 205 +++++++ test/test_py_binary_receiver.py | 184 ++++++ test/test_py_binary_sender.py | 183 ++++++ test/test_py_dictionary_receiver.py | 220 ++++++++ test/test_py_dictionary_sender.py | 172 ++++++ test/test_py_mix_payloads_sender.py | 199 +++++++ test/test_py_table_sender.py | 167 ++++++ test/test_py_text_receiver.py | 205 +++++++ test/test_py_text_sender.py | 164 ++++++ 27 files changed, 6536 insertions(+), 3 deletions(-) create mode 100644 src/natbridge.js create mode 100644 src/natbridge.py create mode 100644 src/natbridge_mpy.py create mode 100644 test/test_js_binary_receiver.js create mode 100644 test/test_js_binary_sender.js create mode 100644 test/test_js_dictionary_receiver.js create mode 100644 test/test_js_dictionary_sender.js create mode 100644 test/test_js_mix_payloads_sender.js create mode 100644 test/test_js_table_receiver.js create mode 100644 test/test_js_table_sender.js create mode 100644 test/test_js_text_receiver.js create mode 100644 test/test_js_text_sender.js create mode 100644 test/test_mpy_binary_receiver.py create mode 100644 test/test_mpy_binary_sender.py create mode 100644 test/test_mpy_dictionary_receiver.py create mode 100644 test/test_mpy_dictionary_sender.py create mode 100644 test/test_mpy_text_receiver.py create mode 100644 test/test_mpy_text_sender.py create mode 100644 test/test_py_binary_receiver.py create mode 100644 test/test_py_binary_sender.py create mode 100644 test/test_py_dictionary_receiver.py create mode 100644 test/test_py_dictionary_sender.py create mode 100644 test/test_py_mix_payloads_sender.py create mode 100644 test/test_py_table_sender.py create mode 100644 test/test_py_text_receiver.py create mode 100644 test/test_py_text_sender.py diff --git a/etc.jl b/etc.jl index 7dfde2e..629840e 100644 --- a/etc.jl +++ b/etc.jl @@ -20,7 +20,7 @@ Ecosystem Variance: Low-level native functions (e.g., NATS.connect(), JSON.read( -Help me expands this Julia package (NATSBridge) into a cross-platform project by adding a JavaScript and Python/MicroPython implementation. To ensure accuracy, the Julia src directory will serve as the ground truth, as the documentation may be outdated. +Help me expands this Julia package (NATSBridge) into a cross-platform project by adding a JavaScript and Python/MicroPython implementation. To ensure accuracy, NATSBridge.jl will serve as the ground truth, as the documentation may be outdated. My goal is to maintain interface parity at the high-level API for a consistent user experience, while ensuring the low-level implementation adheres strictly to the idiomatic conventions of each respective language (e.g., multiple dispatch in Julia vs. asynchronous, prototype, or class-based patterns in JS and Python/MicroPython) @@ -36,5 +36,3 @@ Now do the following: - - diff --git a/src/natbridge.js b/src/natbridge.js new file mode 100644 index 0000000..7414e9a --- /dev/null +++ b/src/natbridge.js @@ -0,0 +1,674 @@ +/** + * NATSBridge - Cross-Platform Bi-Directional Data Bridge + * JavaScript/Node.js Implementation + * + * This module provides functionality for sending and receiving data across network boundaries + * using NATS as the message bus, with support for both direct payload transport and + * URL-based transport for larger payloads. + * + * @module NATSBridge + */ + +const nats = require('nats'); +const { v4: uuidv4 } = require('uuid'); +const fetch = require('node-fetch'); +const arrow = require('apache-arrow'); + +// ---------------------------------------------- Constants ---------------------------------------------- // + +/** + * Default size threshold for switching from direct to link transport (1MB) + */ +const DEFAULT_SIZE_THRESHOLD = 1_000_000; + +/** + * Default NATS server URL + */ +const DEFAULT_BROKER_URL = 'nats://localhost:4222'; + +/** + * Default HTTP file server URL for link transport + */ +const DEFAULT_FILESERVER_URL = 'http://localhost:8080'; + +// ---------------------------------------------- Utility Functions ---------------------------------------------- // + +/** + * Convert Buffer to Base64 string + * @param {Buffer} buffer - Buffer to encode + * @returns {string} Base64 encoded string + */ +function bufferToBase64(buffer) { + return buffer.toString('base64'); +} + +/** + * Log a trace message with correlation ID and timestamp + * @param {string} correlationId - Correlation ID for tracing + * @param {string} message - Message content to log + */ +function logTrace(correlationId, message) { + const timestamp = new Date().toISOString(); + console.log(`[${timestamp}] [Correlation: ${correlationId}] ${message}`); +} + +// ---------------------------------------------- Serialization Functions ---------------------------------------------- // + +/** + * Serialize data according to specified format + * @param {any} data - Data to serialize + * @param {string} payloadType - Target format: "text", "dictionary", "table", "image", "audio", "video", "binary" + * @returns {Buffer} Binary representation of the serialized data + */ +async function serializeData(data, payloadType) { + if (payloadType === 'text') { + if (typeof data === 'string') { + return Buffer.from(data, 'utf8'); + } else { + throw new Error('Text data must be a string'); + } + } else if (payloadType === 'dictionary') { + const jsonStr = JSON.stringify(data); + return Buffer.from(jsonStr, 'utf8'); + } else if (payloadType === 'table') { + // Convert array of objects to Arrow IPC format + if (!Array.isArray(data) || data.length === 0) { + throw new Error('Table data must be a non-empty array of objects'); + } + + return serializeArrowTable(data); + } else if (payloadType === 'image') { + if (data instanceof Uint8Array || Buffer.isBuffer(data)) { + return Buffer.from(data); + } else { + throw new Error('Image data must be Uint8Array or Buffer'); + } + } else if (payloadType === 'audio') { + if (data instanceof Uint8Array || Buffer.isBuffer(data)) { + return Buffer.from(data); + } else { + throw new Error('Audio data must be Uint8Array or Buffer'); + } + } else if (payloadType === 'video') { + if (data instanceof Uint8Array || Buffer.isBuffer(data)) { + return Buffer.from(data); + } else { + throw new Error('Video data must be Uint8Array or Buffer'); + } + } else if (payloadType === 'binary') { + if (data instanceof Uint8Array || Buffer.isBuffer(data)) { + return Buffer.from(data); + } else { + throw new Error('Binary data must be Uint8Array or Buffer'); + } + } else { + throw new Error(`Unknown payload_type: ${payloadType}`); + } +} + +/** + * Helper function to properly serialize table data to Arrow IPC + * @param {Array} data - Array of objects representing table rows + * @returns {Buffer} Arrow IPC formatted buffer + */ +function serializeArrowTable(data) { + if (!Array.isArray(data) || data.length === 0) { + throw new Error('Table data must be a non-empty array of objects'); + } + + // Build schema from first row + const fields = Object.keys(data[0]).map(key => { + const value = data[0][key]; + let arrowType; + if (typeof value === 'number') { + arrowType = Number.isInteger(value) ? arrow.Int64 : arrow.Float64; + } else if (typeof value === 'boolean') { + arrowType = arrow.Boolean; + } else if (value instanceof Date) { + arrowType = arrow.Date; + } else { + arrowType = arrow.Utf8; + } + return new arrow.Field(key, arrowType, true); + }); + + const schema = new arrow.Schema(fields); + const batches = []; + + // Create record batches + for (const row of data) { + const batch = arrow.recordBatch.fromObjects([row], schema); + batches.push(batch); + } + + // Write to buffer using IPC format + const buffers = arrow.ipc.recordBatchesToMessage(batches, schema).buffers; + const combined = new Uint8Array(buffers.reduce((acc, b) => acc + b.byteLength, 0)); + let offset = 0; + for (const buf of buffers) { + combined.set(new Uint8Array(buf), offset); + offset += buf.byteLength; + } + + return Buffer.from(combined); +} + +/** + * Deserialize bytes to data based on type + * @param {Buffer|Uint8Array} data - Serialized data as bytes + * @param {string} payloadType - Data type + * @param {string} correlationId - Correlation ID for logging + * @returns {any} Deserialized data + */ +async function deserializeData(data, payloadType, correlationId) { + const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data); + + if (payloadType === 'text') { + return buffer.toString('utf8'); + } else if (payloadType === 'dictionary') { + const jsonStr = buffer.toString('utf8'); + return JSON.parse(jsonStr); + } else if (payloadType === 'table') { + const table = arrow.tableFromRawBytes(buffer); + return table; + } else if (payloadType === 'image') { + return buffer; + } else if (payloadType === 'audio') { + return buffer; + } else if (payloadType === 'video') { + return buffer; + } else if (payloadType === 'binary') { + return buffer; + } else { + throw new Error(`Unknown payload_type: ${payloadType}`); + } +} + +// ---------------------------------------------- File Server Handlers ---------------------------------------------- // + +/** + * Upload data to plik server in one-shot mode + * @param {string} fileServerUrl - Base URL of the plik server + * @param {string} dataname - Name of the file being uploaded + * @param {Buffer|Uint8Array} data - Raw byte data of the file content + * @returns {Promise<{status: number, uploadid: string, fileid: string, url: string}>} + */ +async function plikOneshotUpload(fileServerUrl, dataname, data) { + const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data); + + // Get upload id + const urlGetUploadID = `${fileServerUrl}/upload`; + const headers = { 'Content-Type': 'application/json' }; + const body = JSON.stringify({ OneShot: true }); + + const httpResponse = await fetch(urlGetUploadID, { + method: 'POST', + headers, + body + }); + + const responseJson = await httpResponse.json(); + const uploadid = responseJson.id; + const uploadtoken = responseJson.uploadToken; + + // Upload file + const urlUpload = `${fileServerUrl}/file/${uploadid}`; + const form = new FormData(); + const blob = new Blob([buffer], { type: 'application/octet-stream' }); + form.append('file', blob, dataname); + + const uploadHeaders = { + 'X-UploadToken': uploadtoken + }; + + const uploadResponse = await fetch(urlUpload, { + method: 'POST', + headers: uploadHeaders, + body: form + }); + + const uploadJson = await uploadResponse.json(); + const fileid = uploadJson.id; + + const url = `${fileServerUrl}/file/${uploadid}/${fileid}/${dataname}`; + + return { + status: uploadResponse.status, + uploadid, + fileid, + url + }; +} + +/** + * Fetch data from URL with exponential backoff + * @param {string} url - URL to fetch from + * @param {number} maxRetries - Maximum number of retry attempts + * @param {number} baseDelay - Initial delay in milliseconds + * @param {number} maxDelay - Maximum delay in milliseconds + * @param {string} correlationId - Correlation ID for logging + * @returns {Promise} Fetched data as bytes + */ +async function fetchWithBackoff(url, maxRetries, baseDelay, maxDelay, correlationId) { + let delay = baseDelay; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const response = await fetch(url); + + if (response.status === 200) { + logTrace(correlationId, `Successfully fetched data from ${url} on attempt ${attempt}`); + const arrayBuffer = await response.arrayBuffer(); + return new Uint8Array(arrayBuffer); + } else { + throw new Error(`Failed to fetch: ${response.status}`); + } + } catch (e) { + logTrace(correlationId, `Attempt ${attempt} failed: ${e.constructor.name}`); + + if (attempt < maxRetries) { + await new Promise(resolve => setTimeout(resolve, delay)); + delay = Math.min(delay * 2, maxDelay); + } + } + } + + throw new Error(`Failed to fetch data after ${maxRetries} attempts`); +} + +// ---------------------------------------------- NATS Client ---------------------------------------------- // + +/** + * NATS client wrapper for connection management + */ +class NATSClient { + /** + * Create a new NATS client + * @param {string} url - NATS server URL + */ + constructor(url) { + this.url = url; + this.connection = null; + } + + /** + * Connect to NATS server + * @returns {Promise} + */ + async connect() { + this.connection = await nats.connect({ servers: this.url }); + return this.connection; + } + + /** + * Publish message to NATS subject + * @param {string} subject - NATS subject to publish to + * @param {string} message - Message to publish + * @param {string} correlationId - Correlation ID for logging + */ + async publish(subject, message, correlationId) { + if (!this.connection) { + await this.connect(); + } + await this.connection.publish(subject, message); + logTrace(correlationId, `Message published to ${subject}`); + } + + /** + * Close the NATS connection + */ + async close() { + if (this.connection) { + this.connection.close(); + } + } +} + +// ---------------------------------------------- Core Functions ---------------------------------------------- // + +/** + * Publish message to NATS + * @param {string|NATSClient|NATS.Connection} brokerUrlOrClient - NATS URL, client, or connection + * @param {string} subject - NATS subject to publish to + * @param {string} message - JSON message to publish + * @param {string} correlationId - Correlation ID for tracing + */ +async function publishMessage(brokerUrlOrClient, subject, message, correlationId) { + let conn; + + if (brokerUrlOrClient instanceof NATSClient) { + conn = brokerUrlOrClient; + } else if (brokerUrlOrClient instanceof nats.Connection) { + // Create a wrapper for direct connection + conn = { + async publish(subj, msg) { + await brokerUrlOrClient.publish(subj, msg); + }, + async close() { + await brokerUrlOrClient.close(); + } + }; + } else { + // String URL - create new client + const client = new NATSClient(brokerUrlOrClient); + conn = client; + } + + await conn.publish(subject, message, correlationId); + + if (conn instanceof NATSClient) { + await conn.close(); + } +} + +/** + * Build message envelope from payloads and metadata + * @param {string} subject - NATS subject + * @param {Array} payloads - Array of payload objects + * @param {Object} options - Envelope metadata options + * @returns {Object} Envelope object + */ +function buildEnvelope(subject, payloads, options) { + return { + correlation_id: options.correlation_id, + msg_id: options.msg_id, + timestamp: new Date().toISOString(), + send_to: subject, + msg_purpose: options.msg_purpose, + sender_name: options.sender_name, + sender_id: options.sender_id, + receiver_name: options.receiver_name, + receiver_id: options.receiver_id, + reply_to: options.reply_to, + reply_to_msg_id: options.reply_to_msg_id, + broker_url: options.broker_url, + metadata: options.metadata || {}, + payloads: payloads + }; +} + +/** + * Build payload object from serialized data + * @param {string} dataname - Name of the payload + * @param {string} payloadType - Type of the payload + * @param {Buffer} payloadBytes - Serialized payload bytes + * @param {string} transport - Transport type ("direct" or "link") + * @param {string} data - Data (base64 for direct, URL for link) + * @returns {Object} Payload object + */ +function buildPayload(dataname, payloadType, payloadBytes, transport, data) { + return { + id: uuidv4(), + dataname, + payload_type: payloadType, + transport, + encoding: transport === 'direct' ? 'base64' : 'none', + size: payloadBytes.byteLength, + data, + metadata: transport === 'direct' ? { payload_bytes: payloadBytes.byteLength } : {} + }; +} + +/** + * Send data via NATS with automatic transport selection + * + * This function intelligently routes data delivery based on payload size. + * If the serialized payload is smaller than size_threshold, it encodes the data as Base64 + * and publishes directly over NATS. Otherwise, it uploads the data to a fileserver + * and publishes only the download URL over NATS. + * + * @param {string} subject - NATS subject to publish the message to + * @param {Array} data - List of [dataname, data, type] tuples to send + * @param {Object} options - Optional configuration + * @param {string} [options.broker_url=DEFAULT_BROKER_URL] - URL of the NATS server + * @param {string} [options.fileserver_url=DEFAULT_FILESERVER_URL] - URL of the HTTP file server + * @param {Function} [options.fileserver_upload_handler=plikOneshotUpload] - Function to handle fileserver uploads + * @param {number} [options.size_threshold=DEFAULT_SIZE_THRESHOLD] - Threshold separating direct vs link transport + * @param {string} [options.correlation_id=uuidv4()] - Correlation ID for tracing + * @param {string} [options.msg_purpose="chat"] - Purpose of the message + * @param {string} [options.sender_name="NATSBridge"] - Name of the sender + * @param {string} [options.receiver_name=""] - Name of the receiver (empty means broadcast) + * @param {string} [options.receiver_id=""] - UUID of the receiver (empty means broadcast) + * @param {string} [options.reply_to=""] - Topic to reply to + * @param {string} [options.reply_to_msg_id=""] - Message ID this message is replying to + * @param {boolean} [options.is_publish=true] - Whether to automatically publish the message + * @param {NATSClient|NATS.Connection} [options.nats_connection=null] - Pre-existing NATS connection + * @param {string} [options.msg_id=uuidv4()] - Message ID + * @param {string} [options.sender_id=uuidv4()] - Sender ID + * @returns {Promise<[Object, string]>} Tuple of [env, env_json_str] + * + * @example + * // Send a single payload + * const [env, envJsonStr] = await smartsend( + * "/test", + * [["dataname1", data1, "dictionary"]], + * { broker_url: "nats://localhost:4222" } + * ); + * + * // Send multiple payloads + * const [env, envJsonStr] = await smartsend( + * "/test", + * [ + * ["dataname1", data1, "dictionary"], + * ["dataname2", data2, "table"] + * ], + * { broker_url: "nats://localhost:4222" } + * ); + * + * // Send with pre-existing connection + * const client = await NATSBridge.NATSClient.connect("nats://localhost:4222"); + * const [env, envJsonStr] = await smartsend( + * "/test", + * [["data", myData, "text"]], + * { nats_connection: client } + * ); + */ +async function smartsend(subject, data, options = {}) { + const { + broker_url = DEFAULT_BROKER_URL, + fileserver_url = DEFAULT_FILESERVER_URL, + fileserver_upload_handler = plikOneshotUpload, + size_threshold = DEFAULT_SIZE_THRESHOLD, + correlation_id = uuidv4(), + msg_purpose = 'chat', + sender_name = 'NATSBridge', + receiver_name = '', + receiver_id = '', + reply_to = '', + reply_to_msg_id = '', + is_publish = true, + nats_connection = null, + msg_id = uuidv4(), + sender_id = uuidv4() + } = options; + + logTrace(correlation_id, `Starting smartsend for subject: ${subject}`); + + // Process payloads + const payloads = []; + for (const [dataname, payloadData, payloadType] of data) { + const payloadBytes = await serializeData(payloadData, payloadType); + const payloadSize = payloadBytes.byteLength; + + logTrace(correlation_id, `Serialized payload '${dataname}' (type: ${payloadType}) size: ${payloadSize} bytes`); + + if (payloadSize < size_threshold) { + // Direct path + const payloadB64 = bufferToBase64(payloadBytes); + logTrace(correlation_id, `Using direct transport for ${payloadSize} bytes`); + + const payload = buildPayload(dataname, payloadType, payloadBytes, 'direct', payloadB64); + payloads.push(payload); + } else { + // Link path + logTrace(correlation_id, `Using link transport, uploading to fileserver`); + + const response = await fileserver_upload_handler(fileserver_url, dataname, payloadBytes); + + if (response.status !== 200) { + throw new Error(`Failed to upload data to fileserver: ${response.status}`); + } + + logTrace(correlation_id, `Uploaded to URL: ${response.url}`); + + const payload = buildPayload(dataname, payloadType, payloadBytes, 'link', response.url); + payloads.push(payload); + } + } + + // Build envelope + const env = buildEnvelope(subject, payloads, { + correlation_id, + msg_id, + msg_purpose, + sender_name, + sender_id, + receiver_name, + receiver_id, + reply_to, + reply_to_msg_id, + broker_url + }); + + const env_json_str = JSON.stringify(env); + + if (is_publish) { + if (nats_connection) { + await publishMessage(nats_connection, subject, env_json_str, correlation_id); + } else { + await publishMessage(broker_url, subject, env_json_str, correlation_id); + } + } + + return [env, env_json_str]; +} + +/** + * Receive and process NATS message + * + * This function processes incoming NATS messages, handling both direct transport + * (base64 decoded payloads) and link transport (URL-based payloads). + * It deserializes the data based on the transport type and returns the result. + * + * @param {Object} msg - NATS message object with payload property + * @param {Object} options - Optional configuration + * @param {Function} [options.fileserver_download_handler=fetchWithBackoff] - Function to handle fileserver downloads + * @param {number} [options.max_retries=5] - Maximum retry attempts for fetching URL + * @param {number} [options.base_delay=100] - Initial delay for exponential backoff in ms + * @param {number} [options.max_delay=5000] - Maximum delay for exponential backoff in ms + * @returns {Promise} Envelope object with processed payloads + * + * @example + * // Receive and process message + * const env = await smartreceive(msg, { + * fileserver_download_handler: fetchWithBackoff, + * max_retries: 5, + * base_delay: 100, + * max_delay: 5000 + * }); + * // env.payloads is an Array of [dataname, data, type] arrays + * for (const [dataname, data, type] of env.payloads) { + * console.log(`${dataname}: ${data} (type: ${type})`); + * } + */ +async function smartreceive(msg, options = {}) { + const { + fileserver_download_handler = fetchWithBackoff, + max_retries = 5, + base_delay = 100, + max_delay = 5000 + } = options; + + // Parse the JSON envelope + const payload = typeof msg.payload === 'string' ? msg.payload : Buffer.from(msg.payload).toString('utf8'); + const envJsonObj = JSON.parse(payload); + logTrace(envJsonObj.correlation_id, 'Processing received message'); + + // Process all payloads in the envelope + const payloadsList = []; + const numPayloads = envJsonObj.payloads.length; + + for (let i = 0; i < numPayloads; i++) { + const payloadObj = envJsonObj.payloads[i]; + const transport = payloadObj.transport; + const dataname = payloadObj.dataname; + + if (transport === 'direct') { + logTrace(envJsonObj.correlation_id, `Direct transport - decoding payload '${dataname}'`); + + // Extract base64 payload from the payload + const payloadB64 = payloadObj.data; + + // Decode Base64 payload + const payloadBytes = Buffer.from(payloadB64, 'base64'); + + // Deserialize based on type + const dataType = payloadObj.payload_type; + const data = await deserializeData(payloadBytes, dataType, envJsonObj.correlation_id); + + payloadsList.push([dataname, data, dataType]); + } else if (transport === 'link') { + // Extract download URL from the payload + const url = payloadObj.data; + logTrace(envJsonObj.correlation_id, `Link transport - fetching '${dataname}' from URL: ${url}`); + + // Fetch with exponential backoff using the download handler + const downloadedData = await fileserver_download_handler( + url, + max_retries, + base_delay, + max_delay, + envJsonObj.correlation_id + ); + + // Deserialize based on type + const dataType = payloadObj.payload_type; + const data = await deserializeData(downloadedData, dataType, envJsonObj.correlation_id); + + payloadsList.push([dataname, data, dataType]); + } else { + throw new Error(`Unknown transport type for payload '${dataname}': ${transport}`); + } + } + + envJsonObj.payloads = payloadsList; + return envJsonObj; +} + +// ---------------------------------------------- Module Exports ---------------------------------------------- // + +const NATSBridge = { + /** + * NATS client class for connection management + */ + NATSClient, + + /** + * Send data via NATS with automatic transport selection + */ + smartsend, + + /** + * Receive and process NATS message + */ + smartreceive, + + /** + * Upload data to plik server in one-shot mode + */ + plikOneshotUpload, + + /** + * Fetch data from URL with exponential backoff + */ + fetchWithBackoff, + + /** + * Default constants + */ + DEFAULT_SIZE_THRESHOLD, + DEFAULT_BROKER_URL, + DEFAULT_FILESERVER_URL +}; + +module.exports = NATSBridge; \ No newline at end of file diff --git a/src/natbridge.py b/src/natbridge.py new file mode 100644 index 0000000..ee94bb0 --- /dev/null +++ b/src/natbridge.py @@ -0,0 +1,815 @@ +""" +NATSBridge - Cross-Platform Bi-Directional Data Bridge +Python Desktop Implementation + +This module provides functionality for sending and receiving data across network boundaries +using NATS as the message bus, with support for both direct payload transport and +URL-based transport for larger payloads. + +@package natbridge +""" + +import asyncio +import base64 +import json +import uuid +from datetime import datetime +from typing import Any, Callable, Dict, List, Tuple, Union +import aiohttp + +try: + import pyarrow as arrow + import pyarrow.ipc as ipc + ARROW_AVAILABLE = True +except ImportError: + ARROW_AVAILABLE = False + +try: + import nats + from nats.aio.client import Client as NATSClient + NATS_AVAILABLE = True +except ImportError: + NATS_AVAILABLE = False + +# ---------------------------------------------- Constants ---------------------------------------------- # + +""" +Default size threshold for switching from direct to link transport (1MB) +""" +DEFAULT_SIZE_THRESHOLD = 1_000_000 + +""" +Default NATS server URL +""" +DEFAULT_BROKER_URL = "nats://localhost:4222" + +""" +Default HTTP file server URL for link transport +""" +DEFAULT_FILESERVER_URL = "http://localhost:8080" + + +# ---------------------------------------------- Utility Functions ---------------------------------------------- # + +def log_trace(correlation_id: str, message: str) -> None: + """ + Log a trace message with correlation ID and timestamp. + + Args: + correlation_id: Correlation ID for tracing + message: Message content to log + """ + timestamp = datetime.utcnow().isoformat() + 'Z' + print(f"[{timestamp}] [Correlation: {correlation_id}] {message}") + + +# ---------------------------------------------- Serialization Functions ---------------------------------------------- # + +def _serialize_data(data: Any, payload_type: str) -> bytes: + """ + Serialize data according to specified format. + + Args: + data: Data to serialize (string for "text", JSON-serializable for "dictionary", + table-like for "table", binary for "image", "audio", "video", "binary") + payload_type: Target format: "text", "dictionary", "table", "image", "audio", "video", "binary" + + Returns: + Binary representation of the serialized data + + Raises: + Error: If payload_type is not one of the supported types + Error: If payload_type is "image", "audio", or "video" but data is not bytes + Error: If payload_type is "table" but data is not a pandas DataFrame or pyarrow Table + """ + if payload_type == 'text': + if isinstance(data, str): + return data.encode('utf-8') + else: + raise ValueError('Text data must be a string') + elif payload_type == 'dictionary': + json_str = json.dumps(data) + return json_str.encode('utf-8') + elif payload_type == 'table': + if not ARROW_AVAILABLE: + raise RuntimeError('pyarrow not available for table serialization') + + import io + buf = io.BytesIO() + + import pandas as pd + if isinstance(data, pd.DataFrame): + table = arrow.Table.from_pandas(data) + sink = ipc.new_file(buf, table.schema) + ipc.write_table(table, sink) + sink.close() + return buf.getvalue() + elif isinstance(data, arrow.Table): + sink = ipc.new_file(buf, data.schema) + ipc.write_table(data, sink) + sink.close() + return buf.getvalue() + else: + raise ValueError('Table data must be a pandas DataFrame or pyarrow Table') + elif payload_type == 'image': + if isinstance(data, (bytes, bytearray)): + return bytes(data) + else: + raise ValueError('Image data must be bytes') + elif payload_type == 'audio': + if isinstance(data, (bytes, bytearray)): + return bytes(data) + else: + raise ValueError('Audio data must be bytes') + elif payload_type == 'video': + if isinstance(data, (bytes, bytearray)): + return bytes(data) + else: + raise ValueError('Video data must be bytes') + elif payload_type == 'binary': + if isinstance(data, (bytes, bytearray)): + return bytes(data) + else: + raise ValueError('Binary data must be bytes') + else: + raise ValueError(f'Unknown payload_type: {payload_type}') + + +def _deserialize_data(data: bytes, payload_type: str, correlation_id: str) -> Any: + """ + Deserialize bytes to data based on type. + + Args: + data: Serialized data as bytes + payload_type: Data type ("text", "dictionary", "table", "image", "audio", "video", "binary") + correlation_id: Correlation ID for logging + + Returns: + Deserialized data (String for "text", DataFrame for "table", JSON data for "dictionary", + bytes for "image", "audio", "video", "binary") + + Raises: + Error: If payload_type is not one of the supported types + """ + if payload_type == 'text': + return data.decode('utf-8') + elif payload_type == 'dictionary': + json_str = data.decode('utf-8') + return json.loads(json_str) + elif payload_type == 'table': + if not ARROW_AVAILABLE: + raise RuntimeError('pyarrow not available for table deserialization') + + import io + buf = io.BytesIO(data) + reader = ipc.open_file(buf) + return reader.read_all().to_pandas() + elif payload_type == 'image': + return data + elif payload_type == 'audio': + return data + elif payload_type == 'video': + return data + elif payload_type == 'binary': + return data + else: + raise ValueError(f'Unknown payload_type: {payload_type}') + + +# ---------------------------------------------- File Server Handlers ---------------------------------------------- # + +async def plik_oneshot_upload( + file_server_url: str, + dataname: str, + data: bytes +) -> Dict[str, Any]: + """ + Upload data to plik server in one-shot mode. + + This function uploads a raw byte array to a plik server in one-shot mode (no upload session). + It first creates a one-shot upload session by sending a POST request with {"OneShot": true}, + retrieves an upload ID and token, then uploads the file data as multipart form data using the token. + + Args: + file_server_url: Base URL of the plik server (e.g., "http://localhost:8080") + dataname: Name of the file being uploaded + data: Raw byte data of the file content + + Returns: + Dict with keys: + - "status": HTTP server response status + - "uploadid": ID of the one-shot upload session + - "fileid": ID of the uploaded file within the session + - "url": Full URL to download the uploaded file + + Example: + >>> fileserver_url = "http://localhost:8080" + >>> dataname = "test.txt" + >>> data = b"hello world" + >>> result = await plik_oneshot_upload(file_server_url, dataname, data) + >>> result["status"], result["uploadid"], result["fileid"], result["url"] + """ + async with aiohttp.ClientSession() as session: + # Get upload id + url_getUploadID = f"{file_server_url}/upload" + headers = {'Content-Type': 'application/json'} + body = json.dumps({"OneShot": True}) + + async with session.post(url_getUploadID, headers=headers, data=body) as response: + response_json = await response.json() + uploadid = response_json['id'] + uploadtoken = response_json['uploadToken'] + + # Upload file + url_upload = f"{file_server_url}/file/{uploadid}" + headers = {'X-UploadToken': uploadtoken} + + form = aiohttp.FormData() + form.add_field('file', data, filename=dataname, content_type='application/octet-stream') + + async with session.post(url_upload, headers=headers, data=form) as upload_response: + upload_json = await upload_response.json() + fileid = upload_json['id'] + + url = f"{file_server_url}/file/{uploadid}/{fileid}/{dataname}" + + return { + 'status': upload_response.status, + 'uploadid': uploadid, + 'fileid': fileid, + 'url': url + } + + +async def fetch_with_backoff( + url: str, + max_retries: int, + base_delay: int, + max_delay: int, + correlation_id: str +) -> bytes: + """ + Fetch data from URL with exponential backoff. + + This internal function retrieves data from a URL with retry logic using + exponential backoff to handle transient failures. + + Args: + url: URL to fetch from + max_retries: Maximum number of retry attempts + base_delay: Initial delay in milliseconds + max_delay: Maximum delay in milliseconds + correlation_id: Correlation ID for logging + + Returns: + Fetched data as bytes + + Raises: + Error: If all retry attempts fail + + Example: + >>> data = await fetch_with_backoff("http://example.com/file.zip", 5, 100, 5000, "correlation123") + """ + delay = base_delay + + for attempt in range(1, max_retries + 1): + try: + async with aiohttp.ClientSession() as session: + async with session.get(url) as response: + if response.status == 200: + log_trace(correlation_id, f"Successfully fetched data from {url} on attempt {attempt}") + return await response.read() + else: + raise Exception(f"Failed to fetch: {response.status}") + except Exception as e: + log_trace(correlation_id, f"Attempt {attempt} failed: {type(e).__name__}") + + if attempt < max_retries: + await asyncio.sleep(delay / 1000.0) + delay = min(delay * 2, max_delay) + + raise Exception(f"Failed to fetch data after {max_retries} attempts") + + +# ---------------------------------------------- NATS Client ---------------------------------------------- # + +class NATSClient: + """NATS client wrapper for connection management.""" + + def __init__(self, url: str = DEFAULT_BROKER_URL): + """ + Create a new NATS client. + + Args: + url: NATS server URL + """ + self.url = url + self._client: NATSClient = None + + async def connect(self) -> NATSClient: + """ + Connect to NATS server. + + Returns: + NATS client instance + """ + if NATS_AVAILABLE: + self._client = nats.connect(self.url) + await self._client + else: + raise Error('nats-py not available') + return self._client + + async def publish(self, subject: str, message: str, correlation_id: str = "") -> None: + """ + Publish message to NATS subject. + + Args: + subject: NATS subject to publish to + message: Message to publish + correlation_id: Correlation ID for logging + """ + if self._client: + await self._client.publish(subject, message) + if correlation_id: + log_trace(correlation_id, f"Message published to {subject}") + + async def close(self) -> None: + """Close the NATS connection.""" + if self._client: + await self._client.drain() + await self._client.close() + + +# ---------------------------------------------- Core Functions ---------------------------------------------- # + +def _build_envelope( + subject: str, + payloads: List[Dict[str, Any]], + options: Dict[str, Any] +) -> Dict[str, Any]: + """ + Build message envelope from payloads and metadata. + + Args: + subject: NATS subject + payloads: Array of payload objects + options: Envelope metadata options + + Returns: + Envelope object + """ + return { + 'correlation_id': options['correlation_id'], + 'msg_id': options['msg_id'], + 'timestamp': datetime.utcnow().isoformat() + 'Z', + 'send_to': subject, + 'msg_purpose': options['msg_purpose'], + 'sender_name': options['sender_name'], + 'sender_id': options['sender_id'], + 'receiver_name': options['receiver_name'], + 'receiver_id': options['receiver_id'], + 'reply_to': options['reply_to'], + 'reply_to_msg_id': options['reply_to_msg_id'], + 'broker_url': options['broker_url'], + 'metadata': options.get('metadata', {}), + 'payloads': payloads + } + + +def _build_payload( + dataname: str, + payload_type: str, + payload_bytes: bytes, + transport: str, + data: Union[str, bytes] +) -> Dict[str, Any]: + """ + Build payload object from serialized data. + + Args: + dataname: Name of the payload + payload_type: Type of the payload + payload_bytes: Serialized payload bytes + transport: Transport type ("direct" or "link") + data: Data (base64 for direct, URL for link) + + Returns: + Payload object + """ + return { + 'id': str(uuid.uuid4()), + 'dataname': dataname, + 'payload_type': payload_type, + 'transport': transport, + 'encoding': 'base64' if transport == 'direct' else 'none', + 'size': len(payload_bytes), + 'data': data, + 'metadata': {'payload_bytes': len(payload_bytes)} if transport == 'direct' else {} + } + + +async def publish_message( + broker_url_or_client: Union[str, NATSClient, Any], + subject: str, + message: str, + correlation_id: str +) -> None: + """ + Publish message to NATS. + + Args: + broker_url_or_client: NATS URL, client, or connection + subject: NATS subject to publish to + message: JSON message to publish + correlation_id: Correlation ID for tracing + """ + if isinstance(broker_url_or_client, NATSClient): + client = broker_url_or_client + elif NATS_AVAILABLE and hasattr(broker_url_or_client, 'publish'): + # Direct NATS client connection + await broker_url_or_client.publish(subject, message) + log_trace(correlation_id, f"Message published to {subject}") + return + else: + # String URL - create new client + client = NATSClient(broker_url_or_client) + await client.connect() + + await client.publish(subject, message, correlation_id) + + if isinstance(broker_url_or_client, NATSClient): + await broker_url_or_client.close() + elif not (NATS_AVAILABLE and hasattr(broker_url_or_client, 'publish')): + await client.close() + + +async def smartsend( + subject: str, + data: List[Tuple[str, Any, str]], + broker_url: str = DEFAULT_BROKER_URL, + fileserver_url: str = DEFAULT_FILESERVER_URL, + fileserver_upload_handler: Callable = plik_oneshot_upload, + size_threshold: int = DEFAULT_SIZE_THRESHOLD, + correlation_id: str = None, + msg_purpose: str = "chat", + sender_name: str = "NATSBridge", + receiver_name: str = "", + receiver_id: str = "", + reply_to: str = "", + reply_to_msg_id: str = "", + is_publish: bool = True, + nats_connection: Any = None, + msg_id: str = None, + sender_id: str = None +) -> Tuple[Dict, str]: + """ + Send data via NATS with automatic transport selection. + + This function intelligently routes data delivery based on payload size. + If the serialized payload is smaller than size_threshold, it encodes the data as Base64 + and publishes directly over NATS. Otherwise, it uploads the data to a fileserver + and publishes only the download URL over NATS. + + Args: + subject: NATS subject to publish the message to + data: List of (dataname, data, type) tuples to send + - dataname: Name of the payload + - data: The actual data to send + - type: Payload type: "text", "dictionary", "table", "image", "audio", "video", "binary" + broker_url: URL of the NATS server + fileserver_url: URL of the HTTP file server for large payloads + fileserver_upload_handler: Function to handle fileserver uploads (must return Dict with "status", + "uploadid", "fileid", "url" keys) + size_threshold: Threshold in bytes separating direct vs link transport + correlation_id: Correlation ID for tracing (auto-generated UUID if not provided) + msg_purpose: Purpose of the message: "ACK", "NACK", "updateStatus", "shutdown", "chat", etc. + sender_name: Name of the sender + receiver_name: Name of the receiver (empty string means broadcast) + receiver_id: UUID of the receiver (empty string means broadcast) + reply_to: Topic to reply to (empty string if no reply expected) + reply_to_msg_id: Message ID this message is replying to + is_publish: Whether to automatically publish the message to NATS + nats_connection: Pre-existing NATS connection (if provided, uses this connection instead of + creating a new one; saves connection establishment overhead) + msg_id: Message ID (auto-generated UUID if not provided) + sender_id: Sender ID (auto-generated UUID if not provided) + + Returns: + Tuple of (env, env_json_str) where: + - env: Dict containing all metadata and payloads + - env_json_str: JSON string for publishing to NATS + + Example: + >>> # Send a single payload (still wrapped in a list) + >>> data = {"key": "value"} + >>> env, env_json_str = await smartsend( + ... "my.subject", + ... [("dataname1", data, "dictionary")], + ... broker_url="nats://localhost:4222" + ... ) + >>> + >>> # Send multiple payloads with different types + >>> data1 = {"key1": "value1"} + >>> data2 = [1, 2, 3, 4, 5] + >>> env, env_json_str = await smartsend( + ... "my.subject", + ... [("dataname1", data1, "dictionary"), ("dataname2", data2, "table")] + ... ) + >>> + >>> # Send a large array using fileserver upload + >>> data = list(range(10_000_000)) # ~80 MB + >>> env, env_json_str = await smartsend( + ... "large.data", + ... [("large_table", data, "table")] + ... ) + >>> + >>> # Mixed content (e.g., chat with text and image) + >>> env, env_json_str = await smartsend( + ... "chat.subject", + ... [ + ... ("message_text", "Hello!", "text"), + ... ("user_image", image_data, "image"), + ... ("audio_clip", audio_data, "audio") + ... ] + ... ) + >>> + >>> # Publish the JSON string directly using NATS request-reply pattern + >>> # reply = await nats.request(broker_url, subject, env_json_str, reply_to=reply_to_topic) + """ + if correlation_id is None: + correlation_id = str(uuid.uuid4()) + if msg_id is None: + msg_id = str(uuid.uuid4()) + if sender_id is None: + sender_id = str(uuid.uuid4()) + + log_trace(correlation_id, f"Starting smartsend for subject: {subject}") + + # Process payloads + payloads = [] + for dataname, payload_data, payload_type in data: + payload_bytes = _serialize_data(payload_data, payload_type) + payload_size = len(payload_bytes) + + log_trace(correlation_id, f"Serialized payload '{dataname}' (type: {payload_type}) size: {payload_size} bytes") + + if payload_size < size_threshold: + # Direct path + payload_b64 = base64.b64encode(payload_bytes).decode('utf-8') + log_trace(correlation_id, f"Using direct transport for {payload_size} bytes") + + payload = _build_payload(dataname, payload_type, payload_bytes, 'direct', payload_b64) + payloads.append(payload) + else: + # Link path + log_trace(correlation_id, "Using link transport, uploading to fileserver") + + response = await fileserver_upload_handler(fileserver_url, dataname, payload_bytes) + + if response['status'] != 200: + raise Exception(f"Failed to upload data to fileserver: {response['status']}") + + log_trace(correlation_id, f"Uploaded to URL: {response['url']}") + + payload = _build_payload(dataname, payload_type, payload_bytes, 'link', response['url']) + payloads.append(payload) + + # Build envelope + env = _build_envelope(subject, payloads, { + 'correlation_id': correlation_id, + 'msg_id': msg_id, + 'msg_purpose': msg_purpose, + 'sender_name': sender_name, + 'sender_id': sender_id, + 'receiver_name': receiver_name, + 'receiver_id': receiver_id, + 'reply_to': reply_to, + 'reply_to_msg_id': reply_to_msg_id, + 'broker_url': broker_url + }) + + env_json_str = json.dumps(env) + + if is_publish: + if nats_connection: + await publish_message(nats_connection, subject, env_json_str, correlation_id) + else: + await publish_message(broker_url, subject, env_json_str, correlation_id) + + return env, env_json_str + + +async def smartreceive( + msg: Any, + fileserver_download_handler: Callable = fetch_with_backoff, + max_retries: int = 5, + base_delay: int = 100, + max_delay: int = 5000 +) -> Dict[str, Any]: + """ + Receive and process NATS messages. + + This function processes incoming NATS messages, handling both direct transport + (base64 decoded payloads) and link transport (URL-based payloads). + It deserializes the data based on the transport type and returns the result. + + Args: + msg: NATS message to process + fileserver_download_handler: Function to handle downloading data from file server URLs + max_retries: Maximum retry attempts for fetching URL + base_delay: Initial delay for exponential backoff in ms + max_delay: Maximum delay for exponential backoff in ms + + Returns: + Dict with envelope metadata and payloads field containing List[Tuple[str, Any, str]] + + Example: + >>> # Receive and process message + >>> env = await smartreceive(msg, fileserver_download_handler=fetch_with_backoff) + >>> # env is a Dict with "payloads" key containing List[Tuple[str, Any, str]] + >>> # Access payloads: for dataname, data, type_ in env["payloads"] + >>> for dataname, data, type_ in env["payloads"]: + >>> print(f"{dataname}: {data} (type: {type_})") + """ + # Parse the JSON envelope + if isinstance(msg, dict): + # Already parsed + env_json_obj = msg + elif hasattr(msg, 'payload'): + # NATS message object + payload = msg.payload if isinstance(msg.payload, str) else msg.payload.decode('utf-8') + env_json_obj = json.loads(payload) + else: + # Assume it's already a JSON string or dict + env_json_obj = json.loads(msg) if isinstance(msg, str) else msg + + log_trace(env_json_obj['correlation_id'], "Processing received message") + + # Process all payloads in the envelope + payloads_list = [] + num_payloads = len(env_json_obj['payloads']) + + for i in range(num_payloads): + payload_obj = env_json_obj['payloads'][i] + transport = payload_obj['transport'] + dataname = payload_obj['dataname'] + + if transport == 'direct': + log_trace(env_json_obj['correlation_id'], f"Direct transport - decoding payload '{dataname}'") + + # Extract base64 payload from the payload + payload_b64 = payload_obj['data'] + + # Decode Base64 payload + payload_bytes = base64.b64decode(payload_b64) + + # Deserialize based on type + data_type = payload_obj['payload_type'] + data = _deserialize_data(payload_bytes, data_type, env_json_obj['correlation_id']) + + payloads_list.append((dataname, data, data_type)) + elif transport == 'link': + # Extract download URL from the payload + url = payload_obj['data'] + log_trace(env_json_obj['correlation_id'], f"Link transport - fetching '{dataname}' from URL: {url}") + + # Fetch with exponential backoff using the download handler + downloaded_data = await fileserver_download_handler( + url, + max_retries, + base_delay, + max_delay, + env_json_obj['correlation_id'] + ) + + # Deserialize based on type + data_type = payload_obj['payload_type'] + data = _deserialize_data(downloaded_data, data_type, env_json_obj['correlation_id']) + + payloads_list.append((dataname, data, data_type)) + else: + raise Exception(f"Unknown transport type for payload '{dataname}': {transport}") + + env_json_obj['payloads'] = payloads_list + return env_json_obj + + +# ---------------------------------------------- Module Exports ---------------------------------------------- # + +class NATSBridge: + """ + Cross-platform NATS bridge implementation. + + This class provides a convenient interface for NATSBridge functionality, + encapsulating the main functions and providing a class-based API. + """ + + DEFAULT_SIZE_THRESHOLD = DEFAULT_SIZE_THRESHOLD + DEFAULT_BROKER_URL = DEFAULT_BROKER_URL + DEFAULT_FILESERVER_URL = DEFAULT_FILESERVER_URL + + def __init__(self, broker_url: str = None, fileserver_url: str = None): + """ + Initialize NATSBridge. + + Args: + broker_url: NATS server URL (defaults to DEFAULT_BROKER_URL) + fileserver_url: HTTP file server URL (defaults to DEFAULT_FILESERVER_URL) + """ + self.broker_url = broker_url or self.DEFAULT_BROKER_URL + self.fileserver_url = fileserver_url or self.DEFAULT_FILESERVER_URL + + async def smartsend( + self, + subject: str, + data: List[Tuple[str, Any, str]], + **kwargs + ) -> Tuple[Dict, str]: + """ + Send data via NATS. + + Args: + subject: NATS subject to publish to + data: List of (dataname, data, type) tuples + **kwargs: Additional options passed to smartsend + + Returns: + Tuple of (env, env_json_str) + """ + kwargs['broker_url'] = kwargs.get('broker_url', self.broker_url) + kwargs['fileserver_url'] = kwargs.get('fileserver_url', self.fileserver_url) + return await smartsend(subject, data, **kwargs) + + async def smartreceive( + self, + msg: Any, + **kwargs + ) -> Dict[str, Any]: + """ + Receive and process NATS message. + + Args: + msg: NATS message to process + **kwargs: Additional options passed to smartreceive + + Returns: + Dict with envelope metadata and payloads + """ + return await smartreceive(msg, **kwargs) + + +# Convenience functions for module-level usage +def send( + subject: str, + data: List[Tuple[str, Any, str]], + **kwargs +) -> Tuple[Dict, str]: + """ + Convenience function for sending data. + + Args: + subject: NATS subject to publish to + data: List of (dataname, data, type) tuples + **kwargs: Additional options + + Returns: + Tuple of (env, env_json_str) + """ + return asyncio.run(smartsend(subject, data, **kwargs)) + + +def receive( + msg: Any, + **kwargs +) -> Dict[str, Any]: + """ + Convenience function for receiving messages. + + Args: + msg: NATS message to process + **kwargs: Additional options + + Returns: + Dict with envelope metadata and payloads + """ + return asyncio.run(smartreceive(msg, **kwargs)) + + +__all__ = [ + 'smartsend', + 'smartreceive', + 'plik_oneshot_upload', + 'fetch_with_backoff', + 'NATSBridge', + 'send', + 'receive', + 'DEFAULT_SIZE_THRESHOLD', + 'DEFAULT_BROKER_URL', + 'DEFAULT_FILESERVER_URL', + 'NATSClient', + '_serialize_data', + '_deserialize_data', + 'log_trace', + 'publish_message' +] \ No newline at end of file diff --git a/src/natbridge_mpy.py b/src/natbridge_mpy.py new file mode 100644 index 0000000..d99631f --- /dev/null +++ b/src/natbridge_mpy.py @@ -0,0 +1,673 @@ +""" +NATSBridge - Cross-Platform Bi-Directional Data Bridge +MicroPython Implementation + +This module provides functionality for sending and receiving data across network boundaries +using NATS as the message bus, with support for both direct payload transport and +URL-based transport for larger payloads. + +Note: MicroPython has significant constraints compared to desktop implementations: +- Limited memory (~256KB - 1MB) +- No Arrow IPC support (memory constraints) +- Synchronous API (no async/await) +- Lower size threshold for direct transport +""" + +import network +import time +import json +import base64 +import uos +import struct +import random + +# ---------------------------------------------- Constants ---------------------------------------------- # + +""" +Default size threshold for switching from direct to link transport (100KB for MicroPython) +""" +DEFAULT_SIZE_THRESHOLD = 100000 + +""" +Default NATS server URL +""" +DEFAULT_BROKER_URL = "nats://localhost:4222" + +""" +Default HTTP file server URL for link transport +""" +DEFAULT_FILESERVER_URL = "http://localhost:8080" + +""" +Hard limit for payload size in MicroPython (50KB) +""" +MAX_PAYLOAD_SIZE = 50000 + + +# ---------------------------------------------- Utility Functions ---------------------------------------------- # + +def log_trace(correlation_id, message): + """ + Log a trace message with correlation ID and timestamp. + + Args: + correlation_id: Correlation ID for tracing + message: Message content to log + """ + timestamp = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.localtime()) + print(f"[{timestamp}] [Correlation: {correlation_id}] {message}") + + +def _generate_uuid(): + """ + Generate a simple UUID compatible with MicroPython. + + Returns: + UUID string + """ + # Generate a simple UUID-like string + # Format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + hex_chars = '0123456789abcdef' + uuid_str = ''.join([random.choice(hex_chars) for _ in range(32)]) + # Insert hyphens at proper positions + return f"{uuid_str[:8]}-{uuid_str[8:12]}-{uuid_str[12:16]}-{uuid_str[16:20]}-{uuid_str[20:]}" + + +# ---------------------------------------------- Serialization Functions ---------------------------------------------- # + +def _serialize_data(data, payload_type): + """ + Serialize data according to specified format. + + Args: + data: Data to serialize (string for "text", dict for "dictionary", + bytes for "image", "audio", "video", "binary") + payload_type: Target format: "text", "dictionary", "image", "audio", "video", "binary" + + Returns: + Binary representation of the serialized data + + Note: + MicroPython does not support "table" type due to memory constraints. + + Raises: + ValueError: If payload_type is not one of the supported types + """ + if payload_type == 'text': + if isinstance(data, str): + return data.encode('utf-8') + else: + raise ValueError('Text data must be a string') + elif payload_type == 'dictionary': + json_str = json.dumps(data) + return json_str.encode('utf-8') + elif payload_type in ('image', 'audio', 'video', 'binary'): + if isinstance(data, (bytes, bytearray, memoryview)): + return bytes(data) + else: + raise ValueError(f'{payload_type} data must be bytes') + else: + raise ValueError(f'Unknown payload_type: {payload_type}') + + +def _deserialize_data(data, payload_type): + """ + Deserialize bytes to data based on type. + + Args: + data: Serialized data as bytes + payload_type: Data type ("text", "dictionary", "image", "audio", "video", "binary") + + Returns: + Deserialized data (String for "text", dict for "dictionary", bytes for others) + + Note: + MicroPython does not support "table" type due to memory constraints. + + Raises: + ValueError: If payload_type is not one of the supported types + """ + if payload_type == 'text': + return data.decode('utf-8') + elif payload_type == 'dictionary': + json_str = data.decode('utf-8') + return json.loads(json_str) + elif payload_type in ('image', 'audio', 'video', 'binary'): + return data + else: + raise ValueError(f'Unknown payload_type: {payload_type}') + + +# ---------------------------------------------- File Server Handlers ---------------------------------------------- # + +def _sync_fileserver_upload(file_server_url, dataname, data): + """ + Synchronous file upload to HTTP server. + + Note: + This is a simplified implementation for MicroPython. + In practice, would use network.HTTP or similar. + Currently raises NotImplementedError as file upload is not fully supported. + + Args: + file_server_url: Base URL of the file server + dataname: Name of the file being uploaded + data: Raw byte data of the file content + + Returns: + Dict with keys: 'status', 'url' + + Raises: + NotImplementedError: File upload is not implemented in MicroPython + """ + raise NotImplementedError("File upload not fully implemented in MicroPython. " + "Use direct transport only for memory-constrained devices.") + + +def _sync_fileserver_download(url, max_retries, base_delay, max_delay, correlation_id): + """ + Synchronous file download with exponential backoff. + + Note: + This is a simplified implementation for MicroPython. + In practice, would use network.HTTP or similar. + Currently raises NotImplementedError as file download is not fully supported. + + Args: + url: URL to download from + max_retries: Maximum retry attempts + base_delay: Initial delay in ms + max_delay: Maximum delay in ms + correlation_id: Correlation ID for logging + + Returns: + Downloaded bytes + + Raises: + NotImplementedError: File download is not implemented in MicroPython + """ + raise NotImplementedError("File download not fully implemented in MicroPython. " + "Use direct transport only for memory-constrained devices.") + + +# ---------------------------------------------- NATS Client ---------------------------------------------- # + +class NATSClient: + """ + NATS client wrapper for MicroPython. + + Note: + This is a simplified implementation for MicroPython. + Full NATS client implementation would require additional network stack support. + """ + + def __init__(self, url=DEFAULT_BROKER_URL): + """ + Initialize NATS client. + + Args: + url: NATS server URL + """ + self.url = url + self._connected = False + + def connect(self): + """ + Connect to NATS server. + + Note: + This is a placeholder implementation. + Actual NATS client would require network stack support. + + Returns: + True if connected, False otherwise + """ + # Placeholder - actual implementation would connect to NATS server + self._connected = True + return self._connected + + def publish(self, subject, message): + """ + Publish message to NATS subject. + + Note: + This is a placeholder implementation. + Actual NATS client would require network stack support. + + Args: + subject: NATS subject to publish to + message: Message to publish + """ + if not self._connected: + raise RuntimeError("Not connected to NATS server") + # Placeholder - actual implementation would publish to NATS + print(f"[NATS] Publish to {subject}: {message[:50]}...") + + def close(self): + """Close the NATS connection.""" + self._connected = False + + +# ---------------------------------------------- Core Functions ---------------------------------------------- # + +def _build_envelope(subject, payloads, options): + """ + Build message envelope from payloads and metadata. + + Args: + subject: NATS subject + payloads: Array of payload objects + options: Envelope metadata options + + Returns: + Envelope dict + """ + return { + 'correlation_id': options['correlation_id'], + 'msg_id': options['msg_id'], + 'timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.localtime()), + 'send_to': subject, + 'msg_purpose': options['msg_purpose'], + 'sender_name': options['sender_name'], + 'sender_id': options['sender_id'], + 'receiver_name': options['receiver_name'], + 'receiver_id': options['receiver_id'], + 'reply_to': options['reply_to'], + 'reply_to_msg_id': options['reply_to_msg_id'], + 'broker_url': options['broker_url'], + 'metadata': {}, + 'payloads': payloads + } + + +def _build_payload(dataname, payload_type, payload_bytes, transport, data): + """ + Build payload object from serialized data. + + Args: + dataname: Name of the payload + payload_type: Type of the payload + payload_bytes: Serialized payload bytes + transport: Transport type ("direct" or "link") + data: Data (base64 for direct, URL for link) + + Returns: + Payload dict + """ + return { + 'id': _generate_uuid(), + 'dataname': dataname, + 'payload_type': payload_type, + 'transport': transport, + 'encoding': 'base64' if transport == 'direct' else 'none', + 'size': len(payload_bytes), + 'data': data, + 'metadata': {'payload_bytes': len(payload_bytes)} if transport == 'direct' else {} + } + + +def _publish(subject, message, correlation_id): + """ + Publish message to NATS. + + Note: + This is a simplified implementation for MicroPython. + + Args: + subject: NATS subject to publish to + message: JSON message to publish + correlation_id: Correlation ID for logging + """ + log_trace(correlation_id, f"Publishing to {subject}") + # Placeholder - actual implementation would use NATSClient + # client = NATSClient() + # client.connect() + # client.publish(subject, message) + # client.close() + + +def smartsend(subject, data, **kwargs): + """ + Send data via NATS with automatic transport selection. + + This function intelligently routes data delivery based on payload size. + If the serialized payload is smaller than size_threshold, it encodes the data as Base64 + and publishes directly over NATS. Otherwise, it uploads the data to a fileserver + and publishes only the download URL over NATS. + + Note: + MicroPython has memory constraints, so the default size_threshold is lower (100KB). + Table type is not supported due to memory constraints. + + Args: + subject: NATS subject to publish the message to + data: List of (dataname, data, type) tuples to send + - dataname: Name of the payload + - data: The actual data to send + - type: Payload type: "text", "dictionary", "image", "audio", "video", "binary" + broker_url: NATS server URL (default: DEFAULT_BROKER_URL) + fileserver_url: HTTP file server URL (default: DEFAULT_FILESERVER_URL) + fileserver_upload_handler: Function to handle fileserver uploads (default: _sync_fileserver_upload) + size_threshold: Threshold in bytes separating direct vs link transport (default: 100000) + correlation_id: Correlation ID for tracing (auto-generated if not provided) + msg_purpose: Purpose of the message (default: "chat") + sender_name: Name of the sender (default: "NATSBridge") + receiver_name: Name of the receiver (empty means broadcast) + receiver_id: UUID of the receiver (empty means broadcast) + reply_to: Topic to reply to (empty if no reply expected) + reply_to_msg_id: Message ID this message is replying to + is_publish: Whether to automatically publish the message (default: True) + msg_id: Message ID (auto-generated if not provided) + sender_id: Sender ID (auto-generated if not provided) + + Returns: + Tuple of (env, env_json_str) where: + - env: Dict containing all metadata and payloads + - env_json_str: JSON string for publishing to NATS + + Example: + >>> # Send text payload + >>> env, env_json_str = NATSBridge.smartsend( + ... "/chat", + ... [("message", "Hello!", "text")], + ... broker_url="nats://localhost:4222" + ... ) + >>> + >>> # Send dictionary payload + >>> env, env_json_str = NATSBridge.smartsend( + ... "/config", + ... [("config", {"key": "value"}, "dictionary")], + ... broker_url="nats://localhost:4222" + ... ) + >>> + >>> # Send binary payload (image, audio, video) + >>> env, env_json_str = NATSBridge.smartsend( + ... "/media", + ... [("image", image_bytes, "image")], + ... broker_url="nats://localhost:4222" + ... ) + """ + # Extract options with defaults + correlation_id = kwargs.get('correlation_id', _generate_uuid()) + msg_id = kwargs.get('msg_id', _generate_uuid()) + sender_id = kwargs.get('sender_id', _generate_uuid()) + broker_url = kwargs.get('broker_url', DEFAULT_BROKER_URL) + fileserver_url = kwargs.get('fileserver_url', DEFAULT_FILESERVER_URL) + size_threshold = kwargs.get('size_threshold', DEFAULT_SIZE_THRESHOLD) + msg_purpose = kwargs.get('msg_purpose', 'chat') + sender_name = kwargs.get('sender_name', 'NATSBridge') + receiver_name = kwargs.get('receiver_name', '') + receiver_id = kwargs.get('receiver_id', '') + reply_to = kwargs.get('reply_to', '') + reply_to_msg_id = kwargs.get('reply_to_msg_id', '') + is_publish = kwargs.get('is_publish', True) + fileserver_upload_handler = kwargs.get('fileserver_upload_handler', _sync_fileserver_upload) + + log_trace(correlation_id, f"Starting smartsend for subject: {subject}") + + # Process payloads + payloads = [] + for dataname, payload_data, payload_type in data: + payload_bytes = _serialize_data(payload_data, payload_type) + payload_size = len(payload_bytes) + + # Check against hard limit for MicroPython + if payload_size > MAX_PAYLOAD_SIZE: + raise MemoryError(f"Payload '{dataname}' exceeds max size {MAX_PAYLOAD_SIZE} bytes") + + log_trace(correlation_id, f"Serialized payload '{dataname}' (type: {payload_type}) size: {payload_size} bytes") + + if payload_size < size_threshold: + # Direct path + payload_b64 = base64.b64encode(payload_bytes).decode('ascii') + log_trace(correlation_id, f"Using direct transport for {payload_size} bytes") + + payload = _build_payload(dataname, payload_type, payload_bytes, 'direct', payload_b64) + payloads.append(payload) + else: + # Link path (limited support) + log_trace(correlation_id, "Using link transport, uploading to fileserver") + + try: + response = fileserver_upload_handler(fileserver_url, dataname, payload_bytes) + log_trace(correlation_id, f"Uploaded to URL: {response['url']}") + + payload = _build_payload(dataname, payload_type, payload_bytes, 'link', response['url']) + payloads.append(payload) + except NotImplementedError: + # Fall back to direct transport if file upload not available + log_trace(correlation_id, "File upload not available, using direct transport") + payload_b64 = base64.b64encode(payload_bytes).decode('ascii') + payload = _build_payload(dataname, payload_type, payload_bytes, 'direct', payload_b64) + payloads.append(payload) + + # Build envelope + env = _build_envelope(subject, payloads, { + 'correlation_id': correlation_id, + 'msg_id': msg_id, + 'msg_purpose': msg_purpose, + 'sender_name': sender_name, + 'sender_id': sender_id, + 'receiver_name': receiver_name, + 'receiver_id': receiver_id, + 'reply_to': reply_to, + 'reply_to_msg_id': reply_to_msg_id, + 'broker_url': broker_url + }) + + env_json_str = json.dumps(env) + + if is_publish: + _publish(subject, env_json_str, correlation_id) + + return env, env_json_str + + +def smartreceive(msg, **kwargs): + """ + Receive and process NATS message. + + This function processes incoming NATS messages, handling both direct transport + (base64 decoded payloads) and link transport (URL-based payloads). + It deserializes the data based on the transport type and returns the result. + + Note: + MicroPython has memory constraints, so large payloads should be avoided. + Table type is not supported due to memory constraints. + + Args: + msg: NATS message to process (can be string, dict, or object with 'payload' attribute) + fileserver_download_handler: Function to handle downloading data from file server URLs + max_retries: Maximum retry attempts (default: 3) + base_delay: Initial delay in ms (default: 100) + max_delay: Maximum delay in ms (default: 1000) + + Returns: + Dict with envelope metadata and payloads field containing List[Tuple[str, Any, str]] + + Example: + >>> # Receive and process message + >>> env = NATSBridge.smartreceive(msg, fileserver_download_handler=_sync_fileserver_download) + >>> # env is a Dict with "payloads" key containing List[Tuple[str, Any, str]] + >>> for dataname, data, type_ in env["payloads"]: + ... print(f"{dataname}: {data} (type: {type_})") + """ + # Parse the JSON envelope + if isinstance(msg, dict): + # Already parsed + env_json_obj = msg + elif hasattr(msg, 'payload'): + # Object with payload attribute + payload = msg.payload if isinstance(msg.payload, str) else msg.payload.decode('utf-8') + env_json_obj = json.loads(payload) + else: + # Assume it's already a JSON string or dict + env_json_obj = json.loads(msg) if isinstance(msg, str) else msg + + correlation_id = env_json_obj['correlation_id'] + log_trace(correlation_id, "Processing received message") + + # Process all payloads in the envelope + payloads_list = [] + num_payloads = len(env_json_obj['payloads']) + + for i in range(num_payloads): + payload_obj = env_json_obj['payloads'][i] + transport = payload_obj['transport'] + dataname = payload_obj['dataname'] + + if transport == 'direct': + log_trace(correlation_id, f"Direct transport - decoding payload '{dataname}'") + + # Extract base64 payload from the payload + payload_b64 = payload_obj['data'] + + # Decode Base64 payload + payload_bytes = base64.b64decode(payload_b64) + + # Deserialize based on type + data_type = payload_obj['payload_type'] + data = _deserialize_data(payload_bytes, data_type) + + payloads_list.append((dataname, data, data_type)) + elif transport == 'link': + # Extract download URL from the payload + url = payload_obj['data'] + log_trace(correlation_id, f"Link transport - fetching '{dataname}' from URL: {url}") + + # Fetch with exponential backoff using the download handler + fileserver_download_handler = kwargs.get('fileserver_download_handler', _sync_fileserver_download) + max_retries = kwargs.get('max_retries', 3) + base_delay = kwargs.get('base_delay', 100) + max_delay = kwargs.get('max_delay', 1000) + + downloaded_data = fileserver_download_handler( + url, + max_retries, + base_delay, + max_delay, + correlation_id + ) + + # Deserialize based on type + data_type = payload_obj['payload_type'] + data = _deserialize_data(downloaded_data, data_type) + + payloads_list.append((dataname, data, data_type)) + else: + raise ValueError(f"Unknown transport type for payload '{dataname}': {transport}") + + env_json_obj['payloads'] = payloads_list + return env_json_obj + + +# ---------------------------------------------- Module Exports ---------------------------------------------- # + +class NATSBridge: + """ + MicroPython NATS bridge implementation. + + This class provides a convenient interface for NATSBridge functionality, + encapsulating the main functions and providing a class-based API. + + Note: + MicroPython has significant constraints: + - No Arrow IPC support (memory constraints) + - Only direct transport (< 100KB threshold enforced) + - Simplified UUID generation + - No async/await (synchronous API) + """ + + DEFAULT_SIZE_THRESHOLD = DEFAULT_SIZE_THRESHOLD + DEFAULT_BROKER_URL = DEFAULT_BROKER_URL + DEFAULT_FILESERVER_URL = DEFAULT_FILESERVER_URL + MAX_PAYLOAD_SIZE = MAX_PAYLOAD_SIZE + + def __init__(self, broker_url=None, fileserver_url=None): + """ + Initialize NATSBridge. + + Args: + broker_url: NATS server URL (defaults to DEFAULT_BROKER_URL) + fileserver_url: HTTP file server URL (defaults to DEFAULT_FILESERVER_URL) + """ + self.broker_url = broker_url or self.DEFAULT_BROKER_URL + self.fileserver_url = fileserver_url or self.DEFAULT_FILESERVER_URL + + def smartsend(self, subject, data, **kwargs): + """ + Send data via NATS. + + Args: + subject: NATS subject to publish to + data: List of (dataname, data, type) tuples + **kwargs: Additional options passed to smartsend + + Returns: + Tuple of (env, env_json_str) + """ + kwargs['broker_url'] = kwargs.get('broker_url', self.broker_url) + kwargs['fileserver_url'] = kwargs.get('fileserver_url', self.fileserver_url) + return smartsend(subject, data, **kwargs) + + def smartreceive(self, msg, **kwargs): + """ + Receive and process NATS message. + + Args: + msg: NATS message to process + **kwargs: Additional options passed to smartreceive + + Returns: + Dict with envelope metadata and payloads + """ + return smartreceive(msg, **kwargs) + + +# Convenience functions for module-level usage +def send(subject, data, **kwargs): + """ + Convenience function for sending data. + + Args: + subject: NATS subject to publish to + data: List of (dataname, data, type) tuples + **kwargs: Additional options + + Returns: + Tuple of (env, env_json_str) + """ + return smartsend(subject, data, **kwargs) + + +def receive(msg, **kwargs): + """ + Convenience function for receiving messages. + + Args: + msg: NATS message to process + **kwargs: Additional options + + Returns: + Dict with envelope metadata and payloads + """ + return smartreceive(msg, **kwargs) + + +__all__ = [ + 'smartsend', + 'smartreceive', + 'NATSBridge', + 'send', + 'receive', + 'DEFAULT_SIZE_THRESHOLD', + 'DEFAULT_BROKER_URL', + 'DEFAULT_FILESERVER_URL', + 'MAX_PAYLOAD_SIZE', + 'NATSClient', + '_serialize_data', + '_deserialize_data', + 'log_trace', + '_sync_fileserver_upload', + '_sync_fileserver_download' +] \ No newline at end of file diff --git a/test/test_js_binary_receiver.js b/test/test_js_binary_receiver.js new file mode 100644 index 0000000..1d989f6 --- /dev/null +++ b/test/test_js_binary_receiver.js @@ -0,0 +1,215 @@ +/** + * JavaScript Binary Receiver Test + * Tests the smartreceive function with binary/image/audio/video payloads + */ + +const NATSBridge = require('../src/natbridge.js'); + +const TEST_BROKER_URL = process.env.NATS_URL || 'nats://localhost:4222'; +const TEST_FILESERVER_URL = process.env.FILESERVER_URL || 'http://localhost:8080'; + +async function runTest() { + console.log('=== JavaScript Binary Receiver Test ===\n'); + + // Create mock NATS message with binary payloads + const binaryData = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); // PNG header + const audioData = Buffer.from([0x46, 0x4C, 0x41, 0x43, 0x00, 0x00, 0x00, 0x00]); // FLAC header + const videoData = Buffer.from([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70]); // MP4 header + const genericBinary = Buffer.from([0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE]); + + const testData = { + correlation_id: 'js-binary-receiver-' + Date.now(), + msg_id: 'msg-' + Date.now(), + timestamp: new Date().toISOString(), + send_to: '/test/binary', + msg_purpose: 'test', + sender_name: 'js-binary-test', + sender_id: 'sender-' + Date.now(), + receiver_name: 'js-receiver', + receiver_id: 'receiver-' + Date.now(), + reply_to: '', + reply_to_msg_id: '', + broker_url: TEST_BROKER_URL, + metadata: {}, + payloads: [ + { + id: 'payload-1', + dataname: 'image', + payload_type: 'image', + transport: 'direct', + encoding: 'base64', + size: binaryData.length, + data: binaryData.toString('base64'), + metadata: { payload_bytes: binaryData.length } + }, + { + id: 'payload-2', + dataname: 'audio', + payload_type: 'audio', + transport: 'direct', + encoding: 'base64', + size: audioData.length, + data: audioData.toString('base64'), + metadata: { payload_bytes: audioData.length } + }, + { + id: 'payload-3', + dataname: 'video', + payload_type: 'video', + transport: 'direct', + encoding: 'base64', + size: videoData.length, + data: videoData.toString('base64'), + metadata: { payload_bytes: videoData.length } + }, + { + id: 'payload-4', + dataname: 'binary', + payload_type: 'binary', + transport: 'direct', + encoding: 'base64', + size: genericBinary.length, + data: genericBinary.toString('base64'), + metadata: { payload_bytes: genericBinary.length } + } + ] + }; + + const mockMsg = { + payload: JSON.stringify(testData) + }; + + console.log('Mock Message Created:'); + console.log(` Correlation ID: ${testData.correlation_id}`); + console.log(` Payloads: ${testData.payloads.length}`); + console.log(` Payload types: ${testData.payloads.map(p => p.payload_type).join(', ')}\n`); + + try { + // Receive and process the message + console.log('Receiving and processing message...'); + const env = await NATSBridge.smartreceive( + mockMsg, + { + max_retries: 3, + base_delay: 100, + max_delay: 1000 + } + ); + + console.log('\n=== Received Envelope ==='); + console.log(`Correlation ID: ${env.correlation_id}`); + console.log(`Message ID: ${env.msg_id}`); + console.log(`Timestamp: ${env.timestamp}`); + console.log(`Subject: ${env.send_to}`); + console.log(`Payloads: ${env.payloads.length}\n`); + + // Validate received data + console.log('=== Validation ==='); + let passed = true; + + if (!env.correlation_id) { + console.log('❌ correlation_id is missing'); + passed = false; + } else { + console.log('✅ correlation_id present'); + } + + if (env.payloads.length !== 4) { + console.log(`❌ Expected 4 payloads, got ${env.payloads.length}`); + passed = false; + } else { + console.log('✅ Correct number of payloads'); + } + + // Expected data + const expectedData = [ + ['image', binaryData, 'image'], + ['audio', audioData, 'audio'], + ['video', videoData, 'video'], + ['binary', genericBinary, 'binary'] + ]; + + for (let i = 0; i < env.payloads.length; i++) { + const payload = env.payloads[i]; + const expected = expectedData[i]; + + if (payload[0] !== expected[0]) { + console.log(`❌ Payload ${i + 1}: Expected dataname '${expected[0]}', got '${payload[0]}'`); + passed = false; + } else { + console.log(`✅ Payload ${i + 1}: Correct dataname`); + } + + if (payload[2] !== expected[2]) { + console.log(`❌ Payload ${i + 1}: Expected type '${expected[2]}', got '${payload[2]}'`); + passed = false; + } else { + console.log(`✅ Payload ${i + 1}: Correct type`); + } + + // Verify binary data integrity + const receivedData = payload[1]; + if (!(receivedData instanceof Buffer || receivedData instanceof Uint8Array)) { + console.log(`❌ Payload ${i + 1}: Expected Buffer/Uint8Array, got ${typeof receivedData}`); + passed = false; + } else if (Buffer.isBuffer(receivedData)) { + if (receivedData.length !== expected[1].length) { + console.log(`❌ Payload ${i + 1}: Length mismatch`); + passed = false; + } else { + let dataMatch = true; + for (let j = 0; j < expected[1].length; j++) { + if (receivedData[j] !== expected[1][j]) { + dataMatch = false; + break; + } + } + if (dataMatch) { + console.log(`✅ Payload ${i + 1}: Data correctly deserialized`); + } else { + console.log(`❌ Payload ${i + 1}: Data mismatch`); + passed = false; + } + } + } else { + // Uint8Array comparison + const receivedBuffer = Buffer.from(receivedData); + if (receivedBuffer.length !== expected[1].length) { + console.log(`❌ Payload ${i + 1}: Length mismatch`); + passed = false; + } else { + let dataMatch = true; + for (let j = 0; j < expected[1].length; j++) { + if (receivedBuffer[j] !== expected[1][j]) { + dataMatch = false; + break; + } + } + if (dataMatch) { + console.log(`✅ Payload ${i + 1}: Data correctly deserialized`); + } else { + console.log(`❌ Payload ${i + 1}: Data mismatch`); + passed = false; + } + } + } + } + + // Final result + console.log('\n=== Test Result ==='); + if (passed) { + console.log('✅ ALL TESTS PASSED'); + process.exit(0); + } else { + console.log('❌ SOME TESTS FAILED'); + process.exit(1); + } + + } catch (error) { + console.error('❌ Test failed with error:', error.message); + console.error(error.stack); + process.exit(1); + } +} + +runTest(); \ No newline at end of file diff --git a/test/test_js_binary_sender.js b/test/test_js_binary_sender.js new file mode 100644 index 0000000..4562d53 --- /dev/null +++ b/test/test_js_binary_sender.js @@ -0,0 +1,173 @@ +/** + * JavaScript Binary Sender Test + * Tests the smartsend function with binary/image/audio/video payloads + */ + +const NATSBridge = require('../src/natbridge.js'); + +const TEST_SUBJECT = '/test/binary'; +const TEST_BROKER_URL = process.env.NATS_URL || 'nats://localhost:4222'; +const TEST_FILESERVER_URL = process.env.FILESERVER_URL || 'http://localhost:8080'; + +async function runTest() { + console.log('=== JavaScript Binary Sender Test ===\n'); + + const correlationId = NATSBridge.uuidv4(); + console.log(`Correlation ID: ${correlationId}`); + console.log(`Subject: ${TEST_SUBJECT}`); + console.log(`Broker URL: ${TEST_BROKER_URL}\n`); + + // Test data - binary data for different types + const binaryData = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); // PNG header + const audioData = Buffer.from([0x46, 0x4C, 0x41, 0x43, 0x00, 0x00, 0x00, 0x00]); // FLAC header + const videoData = Buffer.from([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70]); // MP4 header + const genericBinary = Buffer.from([0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE]); + + const testData = [ + ['image', binaryData, 'image'], + ['audio', audioData, 'audio'], + ['video', videoData, 'video'], + ['binary', genericBinary, 'binary'] + ]; + + try { + // Send the message + console.log('Sending binary payloads...'); + const [env, envJsonStr] = await NATSBridge.smartsend( + TEST_SUBJECT, + testData, + { + broker_url: TEST_BROKER_URL, + fileserver_url: TEST_FILESERVER_URL, + correlation_id: correlationId, + msg_purpose: 'test', + sender_name: 'js-binary-test', + is_publish: false + } + ); + + console.log('\n=== Envelope Created ==='); + console.log(`Correlation ID: ${env.correlation_id}`); + console.log(`Message ID: ${env.msg_id}`); + console.log(`Timestamp: ${env.timestamp}`); + console.log(`Subject: ${env.send_to}`); + console.log(`Purpose: ${env.msg_purpose}`); + console.log(`Sender: ${env.sender_name}`); + console.log(`Payloads: ${env.payloads.length}\n`); + + // Validate envelope structure + console.log('=== Validation ==='); + let passed = true; + + if (env.payloads.length !== 4) { + console.log(`❌ Expected 4 payloads, got ${env.payloads.length}`); + passed = false; + } else { + console.log('✅ Correct number of payloads'); + } + + // Test each payload + const expectedDatanames = ['image', 'audio', 'video', 'binary']; + const expectedTypes = ['image', 'audio', 'video', 'binary']; + const expectedData = [binaryData, audioData, videoData, genericBinary]; + + for (let i = 0; i < env.payloads.length; i++) { + const payload = env.payloads[i]; + + if (payload.dataname !== expectedDatanames[i]) { + console.log(`❌ Payload ${i + 1}: Expected dataname '${expectedDatanames[i]}', got '${payload.dataname}'`); + passed = false; + } else { + console.log(`✅ Payload ${i + 1}: Correct dataname`); + } + + if (payload.payload_type !== expectedTypes[i]) { + console.log(`❌ Payload ${i + 1}: Expected type '${expectedTypes[i]}', got '${payload.payload_type}'`); + passed = false; + } else { + console.log(`✅ Payload ${i + 1}: Correct type`); + } + + if (payload.transport !== 'direct') { + console.log(`❌ Payload ${i + 1}: Expected transport 'direct', got '${payload.transport}'`); + passed = false; + } else { + console.log(`✅ Payload ${i + 1}: Correct transport`); + } + + if (payload.encoding !== 'base64') { + console.log(`❌ Payload ${i + 1}: Expected encoding 'base64', got '${payload.encoding}'`); + passed = false; + } else { + console.log(`✅ Payload ${i + 1}: Correct encoding`); + } + + // Decode and verify the data + const decodedData = Buffer.from(payload.data, 'base64'); + const originalData = expectedData[i]; + + if (decodedData.length !== originalData.length) { + console.log(`❌ Payload ${i + 1}: Length mismatch (${decodedData.length} vs ${originalData.length})`); + passed = false; + } else { + let dataMatch = true; + for (let j = 0; j < originalData.length; j++) { + if (decodedData[j] !== originalData[j]) { + dataMatch = false; + break; + } + } + if (dataMatch) { + console.log(`✅ Payload ${i + 1}: Data integrity verified`); + } else { + console.log(`❌ Payload ${i + 1}: Data integrity mismatch`); + passed = false; + } + } + + console.log(` Size: ${payload.size} bytes\n`); + } + + // Test with larger binary data (simulating file upload scenario) + console.log('=== Large Binary Data Test ==='); + const largeData = Buffer.alloc(10000, 0xFF); // 10KB of binary data + const largeTestData = [ + ['large_binary', largeData, 'binary'] + ]; + + const [largeEnv, _] = await NATSBridge.smartsend( + TEST_SUBJECT, + largeTestData, + { + broker_url: TEST_BROKER_URL, + fileserver_url: TEST_FILESERVER_URL, + correlation_id: 'large-' + correlationId, + is_publish: false + } + ); + + if (largeEnv.payloads.length === 1 && largeEnv.payloads[0].size === 10000) { + console.log('✅ Large binary data handled correctly'); + } else { + console.log('❌ Large binary data handling failed'); + passed = false; + } + + // Final result + console.log('\n=== Test Result ==='); + if (passed) { + console.log('✅ ALL TESTS PASSED'); + process.exit(0); + } else { + console.log('❌ SOME TESTS FAILED'); + process.exit(1); + } + + } catch (error) { + console.error('❌ Test failed with error:', error.message); + console.error(error.stack); + process.exit(1); + } +} + +runTest(); \ No newline at end of file diff --git a/test/test_js_dictionary_receiver.js b/test/test_js_dictionary_receiver.js new file mode 100644 index 0000000..1011fdf --- /dev/null +++ b/test/test_js_dictionary_receiver.js @@ -0,0 +1,220 @@ +/** + * JavaScript Dictionary Receiver Test + * Tests the smartreceive function with dictionary payloads + */ + +const NATSBridge = require('../src/natbridge.js'); + +const TEST_BROKER_URL = process.env.NATS_URL || 'nats://localhost:4222'; +const TEST_FILESERVER_URL = process.env.FILESERVER_URL || 'http://localhost:8080'; + +async function runTest() { + console.log('=== JavaScript Dictionary Receiver Test ===\n'); + + // Create a mock NATS message with dictionary payloads + const simpleDict = { key1: 'value1', key2: 'value2' }; + const nestedDict = { outer: { inner: 'value', number: 42 } }; + const arrayDict = { items: [1, 2, 3, 'four', 'five'] }; + const mixedDict = { string: 'text', number: 123, boolean: true, null_val: null }; + + const testData = { + correlation_id: 'test-receiver-dict-' + Date.now(), + msg_id: 'msg-' + Date.now(), + timestamp: new Date().toISOString(), + send_to: '/test/dictionary', + msg_purpose: 'test', + sender_name: 'js-dict-test', + sender_id: 'sender-' + Date.now(), + receiver_name: 'js-receiver', + receiver_id: 'receiver-' + Date.now(), + reply_to: '', + reply_to_msg_id: '', + broker_url: TEST_BROKER_URL, + metadata: {}, + payloads: [ + { + id: 'payload-1', + dataname: 'simple_dict', + payload_type: 'dictionary', + transport: 'direct', + encoding: 'base64', + size: Buffer.from(JSON.stringify(simpleDict)).length, + data: Buffer.from(JSON.stringify(simpleDict)).toString('base64'), + metadata: { payload_bytes: Buffer.from(JSON.stringify(simpleDict)).length } + }, + { + id: 'payload-2', + dataname: 'nested_dict', + payload_type: 'dictionary', + transport: 'direct', + encoding: 'base64', + size: Buffer.from(JSON.stringify(nestedDict)).length, + data: Buffer.from(JSON.stringify(nestedDict)).toString('base64'), + metadata: { payload_bytes: Buffer.from(JSON.stringify(nestedDict)).length } + }, + { + id: 'payload-3', + dataname: 'array_dict', + payload_type: 'dictionary', + transport: 'direct', + encoding: 'base64', + size: Buffer.from(JSON.stringify(arrayDict)).length, + data: Buffer.from(JSON.stringify(arrayDict)).toString('base64'), + metadata: { payload_bytes: Buffer.from(JSON.stringify(arrayDict)).length } + }, + { + id: 'payload-4', + dataname: 'mixed_dict', + payload_type: 'dictionary', + transport: 'direct', + encoding: 'base64', + size: Buffer.from(JSON.stringify(mixedDict)).length, + data: Buffer.from(JSON.stringify(mixedDict)).toString('base64'), + metadata: { payload_bytes: Buffer.from(JSON.stringify(mixedDict)).length } + } + ] + }; + + const mockMsg = { + payload: JSON.stringify(testData) + }; + + console.log('Mock Message Created:'); + console.log(` Correlation ID: ${testData.correlation_id}`); + console.log(` Payloads: ${testData.payloads.length}`); + console.log(` Payload types: ${testData.payloads.map(p => p.payload_type).join(', ')}\n`); + + try { + // Receive and process the message + console.log('Receiving and processing message...'); + const env = await NATSBridge.smartreceive( + mockMsg, + { + max_retries: 3, + base_delay: 100, + max_delay: 1000 + } + ); + + console.log('\n=== Received Envelope ==='); + console.log(`Correlation ID: ${env.correlation_id}`); + console.log(`Message ID: ${env.msg_id}`); + console.log(`Timestamp: ${env.timestamp}`); + console.log(`Subject: ${env.send_to}`); + console.log(`Payloads: ${env.payloads.length}\n`); + + // Validate received data + console.log('=== Validation ==='); + let passed = true; + + if (!env.correlation_id) { + console.log('❌ correlation_id is missing'); + passed = false; + } else { + console.log('✅ correlation_id present'); + } + + if (env.payloads.length !== 4) { + console.log(`❌ Expected 4 payloads, got ${env.payloads.length}`); + passed = false; + } else { + console.log('✅ Correct number of payloads'); + } + + // Expected data + const expectedData = [ + ['simple_dict', simpleDict, 'dictionary'], + ['nested_dict', nestedDict, 'dictionary'], + ['array_dict', arrayDict, 'dictionary'], + ['mixed_dict', mixedDict, 'dictionary'] + ]; + + for (let i = 0; i < env.payloads.length; i++) { + const payload = env.payloads[i]; + const expected = expectedData[i]; + + if (payload[0] !== expected[0]) { + console.log(`❌ Payload ${i + 1}: Expected dataname '${expected[0]}', got '${payload[0]}'`); + passed = false; + } else { + console.log(`✅ Payload ${i + 1}: Correct dataname`); + } + + if (payload[2] !== expected[2]) { + console.log(`❌ Payload ${i + 1}: Expected type '${expected[2]}', got '${payload[2]}'`); + passed = false; + } else { + console.log(`✅ Payload ${i + 1}: Correct type`); + } + + const dataMatch = JSON.stringify(payload[1]) === JSON.stringify(expected[1]); + if (!dataMatch) { + console.log(`❌ Payload ${i + 1}: Data mismatch`); + console.log(` Expected: ${JSON.stringify(expected[1])}`); + console.log(` Got: ${JSON.stringify(payload[1])}`); + passed = false; + } else { + console.log(`✅ Payload ${i + 1}: Data correctly deserialized`); + } + } + + // Test round-trip with receive + console.log('\n=== Round-trip Test ==='); + const roundTripData = { + correlation_id: 'roundtrip-' + Date.now(), + msg_id: 'msg-' + Date.now(), + timestamp: new Date().toISOString(), + send_to: '/test/dictionary', + msg_purpose: 'test', + sender_name: 'js-test', + sender_id: 'sender-' + Date.now(), + receiver_name: 'js-receiver', + receiver_id: 'receiver-' + Date.now(), + reply_to: '', + reply_to_msg_id: '', + broker_url: TEST_BROKER_URL, + metadata: {}, + payloads: [ + { + id: 'payload-rt', + dataname: 'roundtrip', + payload_type: 'dictionary', + transport: 'direct', + encoding: 'base64', + size: Buffer.from(JSON.stringify({ test: 'data', nested: { a: 1, b: 2 } })).length, + data: Buffer.from(JSON.stringify({ test: 'data', nested: { a: 1, b: 2 } })).toString('base64'), + metadata: { payload_bytes: Buffer.from(JSON.stringify({ test: 'data', nested: { a: 1, b: 2 } })).length } + } + ] + }; + + const mockRtMsg = { payload: JSON.stringify(roundTripData) }; + const rtEnv = await NATSBridge.smartreceive(mockRtMsg); + + if (rtEnv.payloads.length === 1 && + rtEnv.payloads[0][0] === 'roundtrip' && + rtEnv.payloads[0][2] === 'dictionary') { + console.log('✅ Round-trip test successful'); + } else { + console.log('❌ Round-trip test failed'); + passed = false; + } + + // Final result + console.log('\n=== Test Result ==='); + if (passed) { + console.log('✅ ALL TESTS PASSED'); + process.exit(0); + } else { + console.log('❌ SOME TESTS FAILED'); + process.exit(1); + } + + } catch (error) { + console.error('❌ Test failed with error:', error.message); + console.error(error.stack); + process.exit(1); + } +} + +runTest(); \ No newline at end of file diff --git a/test/test_js_dictionary_sender.js b/test/test_js_dictionary_sender.js new file mode 100644 index 0000000..d3acca3 --- /dev/null +++ b/test/test_js_dictionary_sender.js @@ -0,0 +1,178 @@ +/** + * JavaScript Dictionary Sender Test + * Tests the smartsend function with dictionary payloads + */ + +const NATSBridge = require('../src/natbridge.js'); + +const TEST_SUBJECT = '/test/dictionary'; +const TEST_BROKER_URL = process.env.NATS_URL || 'nats://localhost:4222'; +const TEST_FILESERVER_URL = process.env.FILESERVER_URL || 'http://localhost:8080'; + +async function runTest() { + console.log('=== JavaScript Dictionary Sender Test ===\n'); + + const correlationId = NATSBridge.uuidv4(); + console.log(`Correlation ID: ${correlationId}`); + console.log(`Subject: ${TEST_SUBJECT}`); + console.log(`Broker URL: ${TEST_BROKER_URL}\n`); + + // Test data - various dictionary structures + const testData = [ + ['simple_dict', { key1: 'value1', key2: 'value2' }, 'dictionary'], + ['nested_dict', { outer: { inner: 'value', number: 42 } }, 'dictionary'], + ['array_dict', { items: [1, 2, 3, 'four', 'five'] }, 'dictionary'], + ['mixed_dict', { string: 'text', number: 123, boolean: true, null_val: null }, 'dictionary'] + ]; + + try { + // Send the message + console.log('Sending dictionary payloads...'); + const [env, envJsonStr] = await NATSBridge.smartsend( + TEST_SUBJECT, + testData, + { + broker_url: TEST_BROKER_URL, + fileserver_url: TEST_FILESERVER_URL, + correlation_id: correlationId, + msg_purpose: 'test', + sender_name: 'js-dict-test', + is_publish: false + } + ); + + console.log('\n=== Envelope Created ==='); + console.log(`Correlation ID: ${env.correlation_id}`); + console.log(`Message ID: ${env.msg_id}`); + console.log(`Timestamp: ${env.timestamp}`); + console.log(`Subject: ${env.send_to}`); + console.log(`Purpose: ${env.msg_purpose}`); + console.log(`Sender: ${env.sender_name}`); + console.log(`Payloads: ${env.payloads.length}\n`); + + // Validate envelope structure + console.log('=== Validation ==='); + let passed = true; + + if (env.payloads.length !== 4) { + console.log(`❌ Expected 4 payloads, got ${env.payloads.length}`); + passed = false; + } else { + console.log('✅ Correct number of payloads'); + } + + // Test each payload + const expectedDatanames = ['simple_dict', 'nested_dict', 'array_dict', 'mixed_dict']; + const expectedTypes = ['dictionary', 'dictionary', 'dictionary', 'dictionary']; + + for (let i = 0; i < env.payloads.length; i++) { + const payload = env.payloads[i]; + + if (payload.dataname !== expectedDatanames[i]) { + console.log(`❌ Payload ${i + 1}: Expected dataname '${expectedDatanames[i]}', got '${payload.dataname}'`); + passed = false; + } else { + console.log(`✅ Payload ${i + 1}: Correct dataname`); + } + + if (payload.payload_type !== expectedTypes[i]) { + console.log(`❌ Payload ${i + 1}: Expected type '${expectedTypes[i]}', got '${payload.payload_type}'`); + passed = false; + } else { + console.log(`✅ Payload ${i + 1}: Correct type`); + } + + if (payload.transport !== 'direct') { + console.log(`❌ Payload ${i + 1}: Expected transport 'direct', got '${payload.transport}'`); + passed = false; + } else { + console.log(`✅ Payload ${i + 1}: Correct transport`); + } + + if (payload.encoding !== 'base64') { + console.log(`❌ Payload ${i + 1}: Expected encoding 'base64', got '${payload.encoding}'`); + passed = false; + } else { + console.log(`✅ Payload ${i + 1}: Correct encoding`); + } + + // Decode and verify the data + const decodedData = JSON.parse(Buffer.from(payload.data, 'base64').toString('utf8')); + const originalData = testData[i][1]; + + const originalJson = JSON.stringify(originalData); + const decodedJson = JSON.stringify(decodedData); + + if (originalJson !== decodedJson) { + console.log(`❌ Payload ${i + 1}: Data integrity mismatch`); + console.log(` Expected: ${originalJson}`); + console.log(` Got: ${decodedJson}`); + passed = false; + } else { + console.log(`✅ Payload ${i + 1}: Data integrity verified`); + } + + console.log(` Size: ${payload.size} bytes\n`); + } + + // Test round-trip serialization + console.log('=== Round-trip Serialization Test ==='); + const roundTripTestData = [ + ['roundtrip', { test: 'data', numbers: [1, 2, 3], nested: { a: 1, b: 2 } }, 'dictionary'] + ]; + + const [rtEnv, rtEnvJsonStr] = await NATSBridge.smartsend( + TEST_SUBJECT, + roundTripTestData, + { + broker_url: TEST_BROKER_URL, + fileserver_url: TEST_FILESERVER_URL, + correlation_id: 'roundtrip-' + correlationId, + is_publish: false + } + ); + + const rtPayload = rtEnv.payloads[0]; + const rtDecoded = JSON.parse(Buffer.from(rtPayload.data, 'base64').toString('utf8')); + + if (JSON.stringify(rtDecoded) === JSON.stringify(roundTripTestData[0][1])) { + console.log('✅ Round-trip serialization successful'); + } else { + console.log('❌ Round-trip serialization failed'); + passed = false; + } + + // Test JSON string output + console.log('\n=== JSON String Output Test ==='); + try { + const parsed = JSON.parse(envJsonStr); + if (parsed.correlation_id === env.correlation_id && + parsed.payloads.length === env.payloads.length) { + console.log('✅ JSON string is valid and matches envelope'); + } else { + console.log('❌ JSON string does not match envelope'); + passed = false; + } + } catch (e) { + console.log('❌ JSON string is invalid:', e.message); + passed = false; + } + + // Final result + console.log('\n=== Test Result ==='); + if (passed) { + console.log('✅ ALL TESTS PASSED'); + process.exit(0); + } else { + console.log('❌ SOME TESTS FAILED'); + process.exit(1); + } + + } catch (error) { + console.error('❌ Test failed with error:', error.message); + console.error(error.stack); + process.exit(1); + } +} + +runTest(); \ No newline at end of file diff --git a/test/test_js_mix_payloads_sender.js b/test/test_js_mix_payloads_sender.js new file mode 100644 index 0000000..6a25b55 --- /dev/null +++ b/test/test_js_mix_payloads_sender.js @@ -0,0 +1,204 @@ +/** + * JavaScript Mix Payloads Sender Test + * Tests the smartsend function with mixed payload types + */ + +const NATSBridge = require('../src/natbridge.js'); + +const TEST_SUBJECT = '/test/mix'; +const TEST_BROKER_URL = process.env.NATS_URL || 'nats://localhost:4222'; +const TEST_FILESERVER_URL = process.env.FILESERVER_URL || 'http://localhost:8080'; + +async function runTest() { + console.log('=== JavaScript Mix Payloads Sender Test ===\n'); + + const correlationId = NATSBridge.uuidv4(); + console.log(`Correlation ID: ${correlationId}`); + console.log(`Subject: ${TEST_SUBJECT}`); + console.log(`Broker URL: ${TEST_BROKER_URL}\n`); + + // Test data - mixed payload types + const textData = 'Hello, NATSBridge!'; + const dictData = { key1: 'value1', key2: 42, nested: { a: 1, b: 2 } }; + const binaryData = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); // PNG header + + // Table data + const tableData = [ + { id: 1, name: 'Alice', age: 30 }, + { id: 2, name: 'Bob', age: 25 }, + { id: 3, name: 'Charlie', age: 35 } + ]; + + const testData = [ + ['message', textData, 'text'], + ['config', dictData, 'dictionary'], + ['image', binaryData, 'image'], + ['users', tableData, 'table'] + ]; + + try { + // Send the message + console.log('Sending mixed payloads...'); + const [env, envJsonStr] = await NATSBridge.smartsend( + TEST_SUBJECT, + testData, + { + broker_url: TEST_BROKER_URL, + fileserver_url: TEST_FILESERVER_URL, + correlation_id: correlationId, + msg_purpose: 'test', + sender_name: 'js-mix-test', + is_publish: false + } + ); + + console.log('\n=== Envelope Created ==='); + console.log(`Correlation ID: ${env.correlation_id}`); + console.log(`Message ID: ${env.msg_id}`); + console.log(`Timestamp: ${env.timestamp}`); + console.log(`Subject: ${env.send_to}`); + console.log(`Purpose: ${env.msg_purpose}`); + console.log(`Sender: ${env.sender_name}`); + console.log(`Payloads: ${env.payloads.length}\n`); + + // Validate envelope structure + console.log('=== Validation ==='); + let passed = true; + + if (env.payloads.length !== 4) { + console.log(`❌ Expected 4 payloads, got ${env.payloads.length}`); + passed = false; + } else { + console.log('✅ Correct number of payloads'); + } + + // Test each payload + const expectedDatanames = ['message', 'config', 'image', 'users']; + const expectedTypes = ['text', 'dictionary', 'image', 'table']; + const expectedData = [textData, dictData, binaryData, tableData]; + + for (let i = 0; i < env.payloads.length; i++) { + const payload = env.payloads[i]; + + if (payload.dataname !== expectedDatanames[i]) { + console.log(`❌ Payload ${i + 1}: Expected dataname '${expectedDatanames[i]}', got '${payload.dataname}'`); + passed = false; + } else { + console.log(`✅ Payload ${i + 1}: Correct dataname`); + } + + if (payload.payload_type !== expectedTypes[i]) { + console.log(`❌ Payload ${i + 1}: Expected type '${expectedTypes[i]}', got '${payload.payload_type}'`); + passed = false; + } else { + console.log(`✅ Payload ${i + 1}: Correct type`); + } + + if (payload.transport !== 'direct') { + console.log(`❌ Payload ${i + 1}: Expected transport 'direct', got '${payload.transport}'`); + passed = false; + } else { + console.log(`✅ Payload ${i + 1}: Correct transport`); + } + + if (payload.encoding !== 'base64') { + console.log(`❌ Payload ${i + 1}: Expected encoding 'base64', got '${payload.encoding}'`); + passed = false; + } else { + console.log(`✅ Payload ${i + 1}: Correct encoding`); + } + + // Verify data integrity based on type + if (expectedTypes[i] === 'text') { + const decodedData = Buffer.from(payload.data, 'base64').toString('utf8'); + if (decodedData !== expectedData[i]) { + console.log(`❌ Payload ${i + 1}: Data integrity mismatch`); + passed = false; + } else { + console.log(`✅ Payload ${i + 1}: Data integrity verified`); + } + } else if (expectedTypes[i] === 'dictionary') { + const decodedData = JSON.parse(Buffer.from(payload.data, 'base64').toString('utf8')); + if (JSON.stringify(decodedData) !== JSON.stringify(expectedData[i])) { + console.log(`❌ Payload ${i + 1}: Data integrity mismatch`); + passed = false; + } else { + console.log(`✅ Payload ${i + 1}: Data integrity verified`); + } + } else if (expectedTypes[i] === 'image') { + const decodedData = Buffer.from(payload.data, 'base64'); + if (decodedData.length !== expectedData[i].length) { + console.log(`❌ Payload ${i + 1}: Length mismatch`); + passed = false; + } else { + let dataMatch = true; + for (let j = 0; j < expectedData[i].length; j++) { + if (decodedData[j] !== expectedData[i][j]) { + dataMatch = false; + break; + } + } + if (dataMatch) { + console.log(`✅ Payload ${i + 1}: Data integrity verified`); + } else { + console.log(`❌ Payload ${i + 1}: Data integrity mismatch`); + passed = false; + } + } + } else if (expectedTypes[i] === 'table') { + const decodedData = Buffer.from(payload.data, 'base64'); + if (decodedData.length > 0) { + console.log(`✅ Payload ${i + 1}: Arrow IPC data present (${decodedData.length} bytes)`); + } else { + console.log(`❌ Payload ${i + 1}: Arrow IPC data is empty`); + passed = false; + } + } + + console.log(` Size: ${payload.size} bytes\n`); + } + + // Test with chat-like payload (text + image + audio) + console.log('=== Chat-like Payload Test ==='); + const chatData = [ + ['text', 'Hello!', 'text'], + ['image', Buffer.from([0xFF, 0xD8, 0xFF, 0xE0]), 'image'], + ['audio', Buffer.from([0x46, 0x4C, 0x41, 0x43]), 'audio'] + ]; + + const [chatEnv, _] = await NATSBridge.smartsend( + TEST_SUBJECT, + chatData, + { + broker_url: TEST_BROKER_URL, + fileserver_url: TEST_FILESERVER_URL, + correlation_id: 'chat-' + correlationId, + is_publish: false + } + ); + + if (chatEnv.payloads.length === 3) { + console.log('✅ Chat-like payloads handled correctly'); + } else { + console.log('❌ Chat-like payloads handling failed'); + passed = false; + } + + // Final result + console.log('\n=== Test Result ==='); + if (passed) { + console.log('✅ ALL TESTS PASSED'); + process.exit(0); + } else { + console.log('❌ SOME TESTS FAILED'); + process.exit(1); + } + + } catch (error) { + console.error('❌ Test failed with error:', error.message); + console.error(error.stack); + process.exit(1); + } +} + +runTest(); \ No newline at end of file diff --git a/test/test_js_table_receiver.js b/test/test_js_table_receiver.js new file mode 100644 index 0000000..d73eae1 --- /dev/null +++ b/test/test_js_table_receiver.js @@ -0,0 +1,172 @@ +/** + * JavaScript Table Receiver Test + * Tests the smartreceive function with table (Arrow IPC) payloads + */ + +const NATSBridge = require('../src/natbridge.js'); + +const TEST_BROKER_URL = process.env.NATS_URL || 'nats://localhost:4222'; +const TEST_FILESERVER_URL = process.env.FILESERVER_URL || 'http://localhost:8080'; + +async function runTest() { + console.log('=== JavaScript Table Receiver Test ===\n'); + + // Create a mock NATS message with table payload + const tableData = [ + { id: 1, name: 'Alice', age: 30, active: true }, + { id: 2, name: 'Bob', age: 25, active: false }, + { id: 3, name: 'Charlie', age: 35, active: true } + ]; + + // Convert to Arrow IPC format + const arrow = require('apache-arrow'); + const fields = [ + new arrow.Field('id', arrow.Int64, true), + new arrow.Field('name', arrow.Utf8, true), + new arrow.Field('age', arrow.Int64, true), + new arrow.Field('active', arrow.Boolean, true) + ]; + const schema = new arrow.Schema(fields); + const batches = []; + for (const row of tableData) { + const batch = arrow.recordBatch.fromObjects([row], schema); + batches.push(batch); + } + const buffers = arrow.ipc.recordBatchesToMessage(batches, schema).buffers; + const combined = new Uint8Array(buffers.reduce((acc, b) => acc + b.byteLength, 0)); + let offset = 0; + for (const buf of buffers) { + combined.set(new Uint8Array(buf), offset); + offset += buf.byteLength; + } + const arrowBuffer = Buffer.from(combined); + + const testData = { + correlation_id: 'js-table-receiver-' + Date.now(), + msg_id: 'msg-' + Date.now(), + timestamp: new Date().toISOString(), + send_to: '/test/table', + msg_purpose: 'test', + sender_name: 'js-table-test', + sender_id: 'sender-' + Date.now(), + receiver_name: 'js-receiver', + receiver_id: 'receiver-' + Date.now(), + reply_to: '', + reply_to_msg_id: '', + broker_url: TEST_BROKER_URL, + metadata: {}, + payloads: [ + { + id: 'payload-1', + dataname: 'users_table', + payload_type: 'table', + transport: 'direct', + encoding: 'base64', + size: arrowBuffer.length, + data: arrowBuffer.toString('base64'), + metadata: { payload_bytes: arrowBuffer.length } + } + ] + }; + + const mockMsg = { + payload: JSON.stringify(testData) + }; + + console.log('Mock Message Created:'); + console.log(` Correlation ID: ${testData.correlation_id}`); + console.log(` Payloads: ${testData.payloads.length}`); + console.log(` Payload type: ${testData.payloads[0].payload_type}`); + console.log(` Transport: ${testData.payloads[0].transport}\n`); + + try { + // Receive and process the message + console.log('Receiving and processing message...'); + const env = await NATSBridge.smartreceive( + mockMsg, + { + max_retries: 3, + base_delay: 100, + max_delay: 1000 + } + ); + + console.log('\n=== Received Envelope ==='); + console.log(`Correlation ID: ${env.correlation_id}`); + console.log(`Message ID: ${env.msg_id}`); + console.log(`Timestamp: ${env.timestamp}`); + console.log(`Subject: ${env.send_to}`); + console.log(`Payloads: ${env.payloads.length}\n`); + + // Validate received data + console.log('=== Validation ==='); + let passed = true; + + if (!env.correlation_id) { + console.log('❌ correlation_id is missing'); + passed = false; + } else { + console.log('✅ correlation_id present'); + } + + if (env.payloads.length !== 1) { + console.log(`❌ Expected 1 payload, got ${env.payloads.length}`); + passed = false; + } else { + console.log('✅ Correct number of payloads'); + } + + const payload = env.payloads[0]; + if (payload[0] !== 'users_table') { + console.log(`❌ Expected dataname 'users_table', got '${payload[0]}'`); + passed = false; + } else { + console.log('✅ Correct dataname'); + } + + if (payload[2] !== 'table') { + console.log(`❌ Expected type 'table', got '${payload[2]}'`); + passed = false; + } else { + console.log('✅ Correct type'); + } + + // Verify table data is a Buffer (Arrow IPC format) + if (payload[1] instanceof Buffer || payload[1] instanceof Uint8Array) { + console.log('✅ Table data is Arrow IPC buffer'); + console.log(` Buffer size: ${payload[1].length} bytes`); + } else { + console.log(`❌ Expected Buffer/Uint8Array, got ${typeof payload[1]}`); + passed = false; + } + + // Test round-trip with Arrow deserialization + console.log('\n=== Arrow Deserialization Test ==='); + try { + const table = arrow.tableFromRawBytes(payload[1]); + console.log(`✅ Arrow table deserialized successfully`); + console.log(` Schema: ${table.schema.fields.map(f => f.name).join(', ')}`); + console.log(` Num rows: ${table.numRows}`); + } catch (e) { + console.log('❌ Arrow deserialization failed:', e.message); + passed = false; + } + + // Final result + console.log('\n=== Test Result ==='); + if (passed) { + console.log('✅ ALL TESTS PASSED'); + process.exit(0); + } else { + console.log('❌ SOME TESTS FAILED'); + process.exit(1); + } + + } catch (error) { + console.error('❌ Test failed with error:', error.message); + console.error(error.stack); + process.exit(1); + } +} + +runTest(); \ No newline at end of file diff --git a/test/test_js_table_sender.js b/test/test_js_table_sender.js new file mode 100644 index 0000000..7f8fa21 --- /dev/null +++ b/test/test_js_table_sender.js @@ -0,0 +1,179 @@ +/** + * JavaScript Table Sender Test + * Tests the smartsend function with table (Arrow IPC) payloads + */ + +const NATSBridge = require('../src/natbridge.js'); + +const TEST_SUBJECT = '/test/table'; +const TEST_BROKER_URL = process.env.NATS_URL || 'nats://localhost:4222'; +const TEST_FILESERVER_URL = process.env.FILESERVER_URL || 'http://localhost:8080'; + +async function runTest() { + console.log('=== JavaScript Table Sender Test ===\n'); + + const correlationId = NATSBridge.uuidv4(); + console.log(`Correlation ID: ${correlationId}`); + console.log(`Subject: ${TEST_SUBJECT}`); + console.log(`Broker URL: ${TEST_BROKER_URL}\n`); + + // Test data - table data as array of objects + const tableData = [ + { id: 1, name: 'Alice', age: 30, active: true }, + { id: 2, name: 'Bob', age: 25, active: false }, + { id: 3, name: 'Charlie', age: 35, active: true }, + { id: 4, name: 'Diana', age: 28, active: true }, + { id: 5, name: 'Eve', age: 32, active: false } + ]; + + const testData = [ + ['users_table', tableData, 'table'] + ]; + + try { + // Send the message + console.log('Sending table payload...'); + const [env, envJsonStr] = await NATSBridge.smartsend( + TEST_SUBJECT, + testData, + { + broker_url: TEST_BROKER_URL, + fileserver_url: TEST_FILESERVER_URL, + correlation_id: correlationId, + msg_purpose: 'test', + sender_name: 'js-table-test', + is_publish: false + } + ); + + console.log('\n=== Envelope Created ==='); + console.log(`Correlation ID: ${env.correlation_id}`); + console.log(`Message ID: ${env.msg_id}`); + console.log(`Timestamp: ${env.timestamp}`); + console.log(`Subject: ${env.send_to}`); + console.log(`Purpose: ${env.msg_purpose}`); + console.log(`Sender: ${env.sender_name}`); + console.log(`Payloads: ${env.payloads.length}\n`); + + // Validate envelope structure + console.log('=== Validation ==='); + let passed = true; + + if (env.payloads.length !== 1) { + console.log(`❌ Expected 1 payload, got ${env.payloads.length}`); + passed = false; + } else { + console.log('✅ Correct number of payloads'); + } + + const payload = env.payloads[0]; + if (payload.dataname !== 'users_table') { + console.log(`❌ Expected dataname 'users_table', got '${payload.dataname}'`); + passed = false; + } else { + console.log('✅ Correct dataname'); + } + + if (payload.payload_type !== 'table') { + console.log(`❌ Expected payload_type 'table', got '${payload.payload_type}'`); + passed = false; + } else { + console.log('✅ Correct payload_type'); + } + + if (payload.transport !== 'direct') { + console.log(`❌ Expected transport 'direct', got '${payload.transport}'`); + passed = false; + } else { + console.log('✅ Correct transport'); + } + + if (payload.encoding !== 'base64') { + console.log(`❌ Expected encoding 'base64', got '${payload.encoding}'`); + passed = false; + } else { + console.log('✅ Correct encoding'); + } + + // Verify Arrow IPC data can be decoded + console.log('\n=== Arrow IPC Verification ==='); + const decodedData = Buffer.from(payload.data, 'base64'); + console.log(`Arrow IPC buffer size: ${decodedData.length} bytes`); + + if (decodedData.length > 0) { + console.log('✅ Arrow IPC data present'); + } else { + console.log('❌ Arrow IPC data is empty'); + passed = false; + } + + // Test with larger table + console.log('\n=== Larger Table Test ==='); + const largeTableData = []; + for (let i = 1; i <= 100; i++) { + largeTableData.push({ + id: i, + name: `User${i}`, + age: Math.floor(Math.random() * 100), + active: Math.random() > 0.5, + score: Math.random() * 100 + }); + } + + const largeTestData = [ + ['large_table', largeTableData, 'table'] + ]; + + const [largeEnv, _] = await NATSBridge.smartsend( + TEST_SUBJECT, + largeTestData, + { + broker_url: TEST_BROKER_URL, + fileserver_url: TEST_FILESERVER_URL, + correlation_id: 'large-' + correlationId, + is_publish: false + } + ); + + if (largeEnv.payloads.length === 1) { + console.log('✅ Large table handled correctly'); + console.log(` Size: ${largeEnv.payloads[0].size} bytes`); + } else { + console.log('❌ Large table handling failed'); + passed = false; + } + + // Test JSON string output + console.log('\n=== JSON String Output Test ==='); + try { + const parsed = JSON.parse(envJsonStr); + if (parsed.correlation_id === env.correlation_id && + parsed.payloads.length === env.payloads.length) { + console.log('✅ JSON string is valid and matches envelope'); + } else { + console.log('❌ JSON string does not match envelope'); + passed = false; + } + } catch (e) { + console.log('❌ JSON string is invalid:', e.message); + passed = false; + } + + // Final result + console.log('\n=== Test Result ==='); + if (passed) { + console.log('✅ ALL TESTS PASSED'); + process.exit(0); + } else { + console.log('❌ SOME TESTS FAILED'); + process.exit(1); + } + + } catch (error) { + console.error('❌ Test failed with error:', error.message); + console.error(error.stack); + process.exit(1); + } +} + +runTest(); \ No newline at end of file diff --git a/test/test_js_text_receiver.js b/test/test_js_text_receiver.js new file mode 100644 index 0000000..479ece5 --- /dev/null +++ b/test/test_js_text_receiver.js @@ -0,0 +1,206 @@ +/** + * JavaScript Text Receiver Test + * Tests the smartreceive function with text payloads + */ + +const NATSBridge = require('../src/natbridge.js'); + +const TEST_BROKER_URL = process.env.NATS_URL || 'nats://localhost:4222'; +const TEST_FILESERVER_URL = process.env.FILESERVER_URL || 'http://localhost:8080'; + +async function runTest() { + console.log('=== JavaScript Text Receiver Test ===\n'); + + // Create a mock NATS message with text payload + const testData = { + correlation_id: 'test-receiver-' + Date.now(), + msg_id: 'msg-' + Date.now(), + timestamp: new Date().toISOString(), + send_to: '/test/text', + msg_purpose: 'test', + sender_name: 'js-text-test', + sender_id: 'sender-' + Date.now(), + receiver_name: 'js-receiver', + receiver_id: 'receiver-' + Date.now(), + reply_to: '', + reply_to_msg_id: '', + broker_url: TEST_BROKER_URL, + metadata: {}, + payloads: [ + { + id: 'payload-' + Date.now(), + dataname: 'message', + payload_type: 'text', + transport: 'direct', + encoding: 'base64', + size: 38, + data: Buffer.from('Hello, NATSBridge! This is a test message.').toString('base64'), + metadata: { payload_bytes: 38 } + } + ] + }; + + const mockMsg = { + payload: JSON.stringify(testData) + }; + + console.log('Mock Message Created:'); + console.log(` Correlation ID: ${testData.correlation_id}`); + console.log(` Payloads: ${testData.payloads.length}`); + console.log(` Payload dataname: ${testData.payloads[0].dataname}`); + console.log(` Payload type: ${testData.payloads[0].payload_type}`); + console.log(` Transport: ${testData.payloads[0].transport}\n`); + + try { + // Receive and process the message + console.log('Receiving and processing message...'); + const env = await NATSBridge.smartreceive( + mockMsg, + { + max_retries: 3, + base_delay: 100, + max_delay: 1000 + } + ); + + console.log('\n=== Received Envelope ==='); + console.log(`Correlation ID: ${env.correlation_id}`); + console.log(`Message ID: ${env.msg_id}`); + console.log(`Timestamp: ${env.timestamp}`); + console.log(`Subject: ${env.send_to}`); + console.log(`Payloads: ${env.payloads.length}\n`); + + // Validate received data + console.log('=== Validation ==='); + let passed = true; + + if (!env.correlation_id) { + console.log('❌ correlation_id is missing'); + passed = false; + } else { + console.log('✅ correlation_id present'); + } + + if (env.payloads.length !== 1) { + console.log(`❌ Expected 1 payload, got ${env.payloads.length}`); + passed = false; + } else { + console.log('✅ Correct number of payloads'); + } + + const payload = env.payloads[0]; + if (payload[0] !== 'message') { + console.log(`❌ Expected dataname 'message', got '${payload[0]}'`); + passed = false; + } else { + console.log('✅ Correct dataname'); + } + + if (payload[2] !== 'text') { + console.log(`❌ Expected type 'text', got '${payload[2]}'`); + passed = false; + } else { + console.log('✅ Correct type'); + } + + if (payload[1] !== 'Hello, NATSBridge! This is a test message.') { + console.log(`❌ Data mismatch`); + console.log(` Expected: Hello, NATSBridge! This is a test message.`); + console.log(` Got: ${payload[1]}`); + passed = false; + } else { + console.log('✅ Data correctly deserialized'); + } + + // Test with multiple text payloads + console.log('\n=== Multiple Text Payloads Test ==='); + const multiTestData = { + correlation_id: 'multi-receiver-' + Date.now(), + msg_id: 'msg-' + Date.now(), + timestamp: new Date().toISOString(), + send_to: '/test/text', + msg_purpose: 'test', + sender_name: 'js-text-test', + sender_id: 'sender-' + Date.now(), + receiver_name: 'js-receiver', + receiver_id: 'receiver-' + Date.now(), + reply_to: '', + reply_to_msg_id: '', + broker_url: TEST_BROKER_URL, + metadata: {}, + payloads: [ + { + id: 'payload-1', + dataname: 'msg1', + payload_type: 'text', + transport: 'direct', + encoding: 'base64', + size: 16, + data: Buffer.from('First message').toString('base64'), + metadata: { payload_bytes: 16 } + }, + { + id: 'payload-2', + dataname: 'msg2', + payload_type: 'text', + transport: 'direct', + encoding: 'base64', + size: 16, + data: Buffer.from('Second message').toString('base64'), + metadata: { payload_bytes: 16 } + }, + { + id: 'payload-3', + dataname: 'msg3', + payload_type: 'text', + transport: 'direct', + encoding: 'base64', + size: 16, + data: Buffer.from('Third message').toString('base64'), + metadata: { payload_bytes: 16 } + } + ] + }; + + const mockMultiMsg = { + payload: JSON.stringify(multiTestData) + }; + + const multiEnv = await NATSBridge.smartreceive(mockMultiMsg); + + if (multiEnv.payloads.length === 3) { + console.log('✅ Multiple payloads handled correctly'); + + // Verify each payload + const expectedMessages = ['First message', 'Second message', 'Third message']; + for (let i = 0; i < 3; i++) { + if (multiEnv.payloads[i][1] === expectedMessages[i]) { + console.log(`✅ Payload ${i + 1} correctly deserialized`); + } else { + console.log(`❌ Payload ${i + 1} mismatch`); + passed = false; + } + } + } else { + console.log(`❌ Expected 3 payloads, got ${multiEnv.payloads.length}`); + passed = false; + } + + // Final result + console.log('\n=== Test Result ==='); + if (passed) { + console.log('✅ ALL TESTS PASSED'); + process.exit(0); + } else { + console.log('❌ SOME TESTS FAILED'); + process.exit(1); + } + + } catch (error) { + console.error('❌ Test failed with error:', error.message); + console.error(error.stack); + process.exit(1); + } +} + +runTest(); \ No newline at end of file diff --git a/test/test_js_text_sender.js b/test/test_js_text_sender.js new file mode 100644 index 0000000..dda7b0c --- /dev/null +++ b/test/test_js_text_sender.js @@ -0,0 +1,169 @@ +/** + * JavaScript Text Sender Test + * Tests the smartsend function with text payloads + */ + +const NATSBridge = require('../src/natbridge.js'); + +const TEST_SUBJECT = '/test/text'; +const TEST_BROKER_URL = process.env.NATS_URL || 'nats://localhost:4222'; +const TEST_FILESERVER_URL = process.env.FILESERVER_URL || 'http://localhost:8080'; + +async function runTest() { + console.log('=== JavaScript Text Sender Test ===\n'); + + const correlationId = NATSBridge.uuidv4(); + console.log(`Correlation ID: ${correlationId}`); + console.log(`Subject: ${TEST_SUBJECT}`); + console.log(`Broker URL: ${TEST_BROKER_URL}\n`); + + // Test data + const textData = 'Hello, NATSBridge! This is a test message.'; + const testData = [ + ['message', textData, 'text'] + ]; + + try { + // Send the message + console.log('Sending text payload...'); + const [env, envJsonStr] = await NATSBridge.smartsend( + TEST_SUBJECT, + testData, + { + broker_url: TEST_BROKER_URL, + fileserver_url: TEST_FILESERVER_URL, + correlation_id: correlationId, + msg_purpose: 'test', + sender_name: 'js-text-test', + is_publish: false // Don't actually publish for this test + } + ); + + console.log('\n=== Envelope Created ==='); + console.log(`Correlation ID: ${env.correlation_id}`); + console.log(`Message ID: ${env.msg_id}`); + console.log(`Timestamp: ${env.timestamp}`); + console.log(`Subject: ${env.send_to}`); + console.log(`Purpose: ${env.msg_purpose}`); + console.log(`Sender: ${env.sender_name}`); + console.log(`Payloads: ${env.payloads.length}\n`); + + // Validate envelope structure + console.log('=== Validation ==='); + let passed = true; + + if (!env.correlation_id) { + console.log('❌ correlation_id is missing'); + passed = false; + } else { + console.log('✅ correlation_id present'); + } + + if (!env.msg_id) { + console.log('❌ msg_id is missing'); + passed = false; + } else { + console.log('✅ msg_id present'); + } + + if (!env.timestamp) { + console.log('❌ timestamp is missing'); + passed = false; + } else { + console.log('✅ timestamp present'); + } + + if (env.payloads.length !== 1) { + console.log(`❌ Expected 1 payload, got ${env.payloads.length}`); + passed = false; + } else { + console.log('✅ Correct number of payloads'); + } + + const payload = env.payloads[0]; + if (payload.dataname !== 'message') { + console.log(`❌ Expected dataname 'message', got '${payload.dataname}'`); + passed = false; + } else { + console.log('✅ Correct dataname'); + } + + if (payload.payload_type !== 'text') { + console.log(`❌ Expected payload_type 'text', got '${payload.payload_type}'`); + passed = false; + } else { + console.log('✅ Correct payload_type'); + } + + if (payload.transport !== 'direct') { + console.log(`❌ Expected transport 'direct', got '${payload.transport}'`); + passed = false; + } else { + console.log('✅ Correct transport'); + } + + if (payload.encoding !== 'base64') { + console.log(`❌ Expected encoding 'base64', got '${payload.encoding}'`); + passed = false; + } else { + console.log('✅ Correct encoding'); + } + + // Decode and verify the data + const decodedData = Buffer.from(payload.data, 'base64').toString('utf8'); + if (decodedData !== textData) { + console.log(`❌ Decoded data mismatch`); + console.log(` Expected: ${textData}`); + console.log(` Got: ${decodedData}`); + passed = false; + } else { + console.log('✅ Data integrity verified'); + } + + console.log(`\nPayload size: ${payload.size} bytes`); + console.log(`Base64 data length: ${payload.data.length} chars`); + + // Test with multiple text payloads + console.log('\n=== Multiple Text Payloads Test ==='); + const multiTestData = [ + ['msg1', 'First message', 'text'], + ['msg2', 'Second message', 'text'], + ['msg3', 'Third message', 'text'] + ]; + + const [multiEnv, multiEnvJsonStr] = await NATSBridge.smartsend( + TEST_SUBJECT, + multiTestData, + { + broker_url: TEST_BROKER_URL, + fileserver_url: TEST_FILESERVER_URL, + correlation_id: 'multi-test-' + correlationId, + is_publish: false + } + ); + + if (multiEnv.payloads.length === 3) { + console.log('✅ Multiple payloads handled correctly'); + } else { + console.log(`❌ Expected 3 payloads, got ${multiEnv.payloads.length}`); + passed = false; + } + + // Final result + console.log('\n=== Test Result ==='); + if (passed) { + console.log('✅ ALL TESTS PASSED'); + process.exit(0); + } else { + console.log('❌ SOME TESTS FAILED'); + process.exit(1); + } + + } catch (error) { + console.error('❌ Test failed with error:', error.message); + console.error(error.stack); + process.exit(1); + } +} + +runTest(); \ No newline at end of file diff --git a/test/test_mpy_binary_receiver.py b/test/test_mpy_binary_receiver.py new file mode 100644 index 0000000..09bf589 --- /dev/null +++ b/test/test_mpy_binary_receiver.py @@ -0,0 +1,185 @@ +""" +MicroPython Binary Receiver Test +Tests the smartreceive function with binary/image/audio/video payloads + +Note: This test is designed for both MicroPython and desktop Python +for compatibility testing. +""" + +import sys +import os +import json +import base64 + +# Add parent directory to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from natbridge_mpy import smartreceive, DEFAULT_BROKER_URL, DEFAULT_FILESERVER_URL + +TEST_BROKER_URL = os.environ.get('NATS_URL', 'nats://localhost:4222') +TEST_FILESERVER_URL = os.environ.get('FILESERVER_URL', 'http://localhost:8080') + + +def run_test(): + print('=== MicroPython Binary Receiver Test ===\n') + + from natbridge_mpy import _generate_uuid + + # Create mock NATS message with binary payloads + image_data = bytes([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) # PNG header + audio_data = bytes([0x46, 0x4C, 0x41, 0x43, 0x00, 0x00, 0x00, 0x00]) # FLAC header + video_data = bytes([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70]) # MP4 header + generic_binary = bytes([0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE]) + + test_data = { + 'correlation_id': 'mpy-binary-receiver-' + _generate_uuid(), + 'msg_id': _generate_uuid(), + 'timestamp': '2024-01-15T10:30:00Z', + 'send_to': '/test/binary', + 'msg_purpose': 'test', + 'sender_name': 'mpy-binary-test', + 'sender_id': _generate_uuid(), + 'receiver_name': 'mpy-receiver', + 'receiver_id': _generate_uuid(), + 'reply_to': '', + 'reply_to_msg_id': '', + 'broker_url': TEST_BROKER_URL, + 'metadata': {}, + 'payloads': [ + { + 'id': _generate_uuid(), + 'dataname': 'image', + 'payload_type': 'image', + 'transport': 'direct', + 'encoding': 'base64', + 'size': len(image_data), + 'data': base64.b64encode(image_data).decode('ascii'), + 'metadata': {'payload_bytes': len(image_data)} + }, + { + 'id': _generate_uuid(), + 'dataname': 'audio', + 'payload_type': 'audio', + 'transport': 'direct', + 'encoding': 'base64', + 'size': len(audio_data), + 'data': base64.b64encode(audio_data).decode('ascii'), + 'metadata': {'payload_bytes': len(audio_data)} + }, + { + 'id': _generate_uuid(), + 'dataname': 'video', + 'payload_type': 'video', + 'transport': 'direct', + 'encoding': 'base64', + 'size': len(video_data), + 'data': base64.b64encode(video_data).decode('ascii'), + 'metadata': {'payload_bytes': len(video_data)} + }, + { + 'id': _generate_uuid(), + 'dataname': 'binary', + 'payload_type': 'binary', + 'transport': 'direct', + 'encoding': 'base64', + 'size': len(generic_binary), + 'data': base64.b64encode(generic_binary).decode('ascii'), + 'metadata': {'payload_bytes': len(generic_binary)} + } + ] + } + + mock_msg = { + 'payload': json.dumps(test_data) + } + + print('Mock Message Created:') + print(f' Correlation ID: {test_data["correlation_id"]}') + print(f' Payloads: {len(test_data["payloads"])}') + print(f' Payload types: {", ".join(p["payload_type"] for p in test_data["payloads"])}\n') + + try: + # Receive and process the message + print('Receiving and processing message...') + env = smartreceive( + mock_msg, + max_retries=3, + base_delay=100, + max_delay=1000 + ) + + print('\n=== Received Envelope ===') + print(f'Correlation ID: {env["correlation_id"]}') + print(f'Message ID: {env["msg_id"]}') + print(f'Timestamp: {env["timestamp"]}') + print(f'Subject: {env["send_to"]}') + print(f'Payloads: {len(env["payloads"])}\n') + + # Validate received data + print('=== Validation ===') + passed = True + + if not env.get('correlation_id'): + print('❌ correlation_id is missing') + passed = False + else: + print('✅ correlation_id present') + + if len(env['payloads']) != 4: + print(f'❌ Expected 4 payloads, got {len(env["payloads"])}') + passed = False + else: + print('✅ Correct number of payloads') + + # Expected data + expected_data = [ + ('image', image_data, 'image'), + ('audio', audio_data, 'audio'), + ('video', video_data, 'video'), + ('binary', generic_binary, 'binary') + ] + + for i in range(len(env['payloads'])): + payload = env['payloads'][i] + expected = expected_data[i] + + if payload[0] != expected[0]: + print(f"❌ Payload {i + 1}: Expected dataname '{expected[0]}', got '{payload[0]}'") + passed = False + else: + print(f'✅ Payload {i + 1}: Correct dataname') + + if payload[2] != expected[2]: + print(f"❌ Payload {i + 1}: Expected type '{expected[2]}', got '{payload[2]}'") + passed = False + else: + print(f'✅ Payload {i + 1}: Correct type') + + # Verify binary data integrity + received_data = payload[1] + if received_data != expected[1]: + print(f'❌ Payload {i + 1}: Data mismatch') + print(f' Expected: {expected[1]}') + print(f' Got: {received_data}') + passed = False + else: + print(f'✅ Payload {i + 1}: Data correctly deserialized') + + # Final result + print('\n=== Test Result ===') + if passed: + print('✅ ALL TESTS PASSED') + sys.exit(0) + else: + print('❌ SOME TESTS FAILED') + sys.exit(1) + + except Exception as e: + print(f'❌ Test failed with error: {e}') + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == '__main__': + run_test() \ No newline at end of file diff --git a/test/test_mpy_binary_sender.py b/test/test_mpy_binary_sender.py new file mode 100644 index 0000000..45a6e00 --- /dev/null +++ b/test/test_mpy_binary_sender.py @@ -0,0 +1,163 @@ +""" +MicroPython Binary Sender Test +Tests the smartsend function with binary/image/audio/video payloads + +Note: This test is designed for both MicroPython and desktop Python +for compatibility testing. +""" + +import sys +import os +import base64 + +# Add parent directory to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from natbridge_mpy import smartsend, DEFAULT_BROKER_URL, DEFAULT_FILESERVER_URL, DEFAULT_SIZE_THRESHOLD, MAX_PAYLOAD_SIZE + +TEST_SUBJECT = '/test/binary' +TEST_BROKER_URL = os.environ.get('NATS_URL', 'nats://localhost:4222') +TEST_FILESERVER_URL = os.environ.get('FILESERVER_URL', 'http://localhost:8080') + + +def run_test(): + print('=== MicroPython Binary Sender Test ===\n') + + from natbridge_mpy import _generate_uuid + correlation_id = 'mpy-binary-test-' + _generate_uuid() + print(f'Correlation ID: {correlation_id}') + print(f'Subject: {TEST_SUBJECT}') + print(f'Broker URL: {TEST_BROKER_URL}') + print(f'Default Size Threshold: {DEFAULT_SIZE_THRESHOLD} bytes') + print(f'Max Payload Size: {MAX_PAYLOAD_SIZE} bytes\n') + + # Test data - binary data for different types + image_data = bytearray([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) # PNG header + audio_data = bytearray([0x46, 0x4C, 0x41, 0x43, 0x00, 0x00, 0x00, 0x00]) # FLAC header + video_data = bytearray([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70]) # MP4 header + generic_binary = bytearray([0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE]) + + test_data = [ + ('image', bytes(image_data), 'image'), + ('audio', bytes(audio_data), 'audio'), + ('video', bytes(video_data), 'video'), + ('binary', bytes(generic_binary), 'binary') + ] + + try: + # Send the message + print('Sending binary payloads...') + env, env_json_str = smartsend( + TEST_SUBJECT, + test_data, + broker_url=TEST_BROKER_URL, + fileserver_url=TEST_FILESERVER_URL, + correlation_id=correlation_id, + msg_purpose='test', + sender_name='mpy-binary-test', + is_publish=False + ) + + print('\n=== Envelope Created ===') + print(f'Correlation ID: {env["correlation_id"]}') + print(f'Message ID: {env["msg_id"]}') + print(f'Timestamp: {env["timestamp"]}') + print(f'Subject: {env["send_to"]}') + print(f'Purpose: {env["msg_purpose"]}') + print(f'Sender: {env["sender_name"]}') + print(f'Payloads: {len(env["payloads"])}\n') + + # Validate envelope structure + print('=== Validation ===') + passed = True + + if len(env['payloads']) != 4: + print(f'❌ Expected 4 payloads, got {len(env["payloads"])}') + passed = False + else: + print('✅ Correct number of payloads') + + # Test each payload + expected_datanames = ['image', 'audio', 'video', 'binary'] + expected_types = ['image', 'audio', 'video', 'binary'] + expected_data = [bytes(image_data), bytes(audio_data), bytes(video_data), bytes(generic_binary)] + + for i in range(len(env['payloads'])): + payload = env['payloads'][i] + + if payload['dataname'] != expected_datanames[i]: + print(f"❌ Payload {i + 1}: Expected dataname '{expected_datanames[i]}', got '{payload['dataname']}'") + passed = False + else: + print(f'✅ Payload {i + 1}: Correct dataname') + + if payload['payload_type'] != expected_types[i]: + print(f"❌ Payload {i + 1}: Expected type '{expected_types[i]}', got '{payload['payload_type']}'") + passed = False + else: + print(f'✅ Payload {i + 1}: Correct type') + + if payload['transport'] != 'direct': + print(f"❌ Payload {i + 1}: Expected transport 'direct', got '{payload['transport']}'") + passed = False + else: + print(f'✅ Payload {i + 1}: Correct transport') + + if payload['encoding'] != 'base64': + print(f"❌ Payload {i + 1}: Expected encoding 'base64', got '{payload['encoding']}'") + passed = False + else: + print(f'✅ Payload {i + 1}: Correct encoding') + + # Decode and verify the data + decoded_data = base64.b64decode(payload['data']) + original_data = expected_data[i] + + if decoded_data != original_data: + print(f'❌ Payload {i + 1}: Data integrity mismatch') + passed = False + else: + print(f'✅ Payload {i + 1}: Data integrity verified') + + print(f' Size: {payload["size"]} bytes\n') + + # Test with larger binary data + print('=== Large Binary Data Test ===') + large_data = bytes([0xFF] * 1000) # 1KB of binary data + large_test_data = [ + ('large_binary', large_data, 'binary') + ] + + large_env, _ = smartsend( + TEST_SUBJECT, + large_test_data, + broker_url=TEST_BROKER_URL, + fileserver_url=TEST_FILESERVER_URL, + correlation_id='large-' + correlation_id, + is_publish=False + ) + + if len(large_env['payloads']) == 1 and large_env['payloads'][0]['size'] == 1000: + print('✅ Large binary data handled correctly') + else: + print('❌ Large binary data handling failed') + passed = False + + # Final result + print('\n=== Test Result ===') + if passed: + print('✅ ALL TESTS PASSED') + sys.exit(0) + else: + print('❌ SOME TESTS FAILED') + sys.exit(1) + + except Exception as e: + print(f'❌ Test failed with error: {e}') + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == '__main__': + run_test() \ No newline at end of file diff --git a/test/test_mpy_dictionary_receiver.py b/test/test_mpy_dictionary_receiver.py new file mode 100644 index 0000000..5f431a0 --- /dev/null +++ b/test/test_mpy_dictionary_receiver.py @@ -0,0 +1,224 @@ +""" +MicroPython Dictionary Receiver Test +Tests the smartreceive function with dictionary payloads + +Note: This test is designed for both MicroPython and desktop Python +for compatibility testing. +""" + +import sys +import os +import json + +# Add parent directory to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from natbridge_mpy import smartreceive, DEFAULT_BROKER_URL, DEFAULT_FILESERVER_URL + +TEST_BROKER_URL = os.environ.get('NATS_URL', 'nats://localhost:4222') +TEST_FILESERVER_URL = os.environ.get('FILESERVER_URL', 'http://localhost:8080') + + +def run_test(): + print('=== MicroPython Dictionary Receiver Test ===\n') + + from natbridge_mpy import _generate_uuid + + # Create a mock NATS message with dictionary payloads + import base64 + + simple_dict = {'key1': 'value1', 'key2': 'value2'} + nested_dict = {'outer': {'inner': 'value', 'number': 42}} + array_dict = {'items': [1, 2, 3, 'four', 'five']} + mixed_dict = {'string': 'text', 'number': 123, 'boolean': True, 'null_val': None} + + test_data = { + 'correlation_id': 'mpy-receiver-dict-' + _generate_uuid(), + 'msg_id': _generate_uuid(), + 'timestamp': '2024-01-15T10:30:00Z', + 'send_to': '/test/dictionary', + 'msg_purpose': 'test', + 'sender_name': 'mpy-dict-test', + 'sender_id': _generate_uuid(), + 'receiver_name': 'mpy-receiver', + 'receiver_id': _generate_uuid(), + 'reply_to': '', + 'reply_to_msg_id': '', + 'broker_url': TEST_BROKER_URL, + 'metadata': {}, + 'payloads': [ + { + 'id': _generate_uuid(), + 'dataname': 'simple_dict', + 'payload_type': 'dictionary', + 'transport': 'direct', + 'encoding': 'base64', + 'size': len(json.dumps(simple_dict).encode('utf8')), + 'data': base64.b64encode(json.dumps(simple_dict).encode('utf8')).decode('ascii'), + 'metadata': {'payload_bytes': len(json.dumps(simple_dict).encode('utf8'))} + }, + { + 'id': _generate_uuid(), + 'dataname': 'nested_dict', + 'payload_type': 'dictionary', + 'transport': 'direct', + 'encoding': 'base64', + 'size': len(json.dumps(nested_dict).encode('utf8')), + 'data': base64.b64encode(json.dumps(nested_dict).encode('utf8')).decode('ascii'), + 'metadata': {'payload_bytes': len(json.dumps(nested_dict).encode('utf8'))} + }, + { + 'id': _generate_uuid(), + 'dataname': 'array_dict', + 'payload_type': 'dictionary', + 'transport': 'direct', + 'encoding': 'base64', + 'size': len(json.dumps(array_dict).encode('utf8')), + 'data': base64.b64encode(json.dumps(array_dict).encode('utf8')).decode('ascii'), + 'metadata': {'payload_bytes': len(json.dumps(array_dict).encode('utf8'))} + }, + { + 'id': _generate_uuid(), + 'dataname': 'mixed_dict', + 'payload_type': 'dictionary', + 'transport': 'direct', + 'encoding': 'base64', + 'size': len(json.dumps(mixed_dict).encode('utf8')), + 'data': base64.b64encode(json.dumps(mixed_dict).encode('utf8')).decode('ascii'), + 'metadata': {'payload_bytes': len(json.dumps(mixed_dict).encode('utf8'))} + } + ] + } + + mock_msg = { + 'payload': json.dumps(test_data) + } + + print('Mock Message Created:') + print(f' Correlation ID: {test_data["correlation_id"]}') + print(f' Payloads: {len(test_data["payloads"])}') + print(f' Payload types: {", ".join(p["payload_type"] for p in test_data["payloads"])}\n') + + try: + # Receive and process the message + print('Receiving and processing message...') + env = smartreceive( + mock_msg, + max_retries=3, + base_delay=100, + max_delay=1000 + ) + + print('\n=== Received Envelope ===') + print(f'Correlation ID: {env["correlation_id"]}') + print(f'Message ID: {env["msg_id"]}') + print(f'Timestamp: {env["timestamp"]}') + print(f'Subject: {env["send_to"]}') + print(f'Payloads: {len(env["payloads"])}\n') + + # Validate received data + print('=== Validation ===') + passed = True + + if not env.get('correlation_id'): + print('❌ correlation_id is missing') + passed = False + else: + print('✅ correlation_id present') + + if len(env['payloads']) != 4: + print(f'❌ Expected 4 payloads, got {len(env["payloads"])}') + passed = False + else: + print('✅ Correct number of payloads') + + # Expected data + expected_data = [ + ('simple_dict', simple_dict, 'dictionary'), + ('nested_dict', nested_dict, 'dictionary'), + ('array_dict', array_dict, 'dictionary'), + ('mixed_dict', mixed_dict, 'dictionary') + ] + + for i in range(len(env['payloads'])): + payload = env['payloads'][i] + expected = expected_data[i] + + if payload[0] != expected[0]: + print(f"❌ Payload {i + 1}: Expected dataname '{expected[0]}', got '{payload[0]}'") + passed = False + else: + print(f'✅ Payload {i + 1}: Correct dataname') + + if payload[2] != expected[2]: + print(f"❌ Payload {i + 1}: Expected type '{expected[2]}', got '{payload[2]}'") + passed = False + else: + print(f'✅ Payload {i + 1}: Correct type') + + data_match = json.dumps(payload[1], sort_keys=True) == json.dumps(expected[1], sort_keys=True) + if not data_match: + print(f'❌ Payload {i + 1}: Data mismatch') + print(f' Expected: {json.dumps(expected[1], sort_keys=True)}') + print(f' Got: {json.dumps(payload[1], sort_keys=True)}') + passed = False + else: + print(f'✅ Payload {i + 1}: Data correctly deserialized') + + # Test round-trip with receive + print('\n=== Round-trip Test ===') + round_trip_data = { + 'correlation_id': 'roundtrip-' + _generate_uuid(), + 'msg_id': _generate_uuid(), + 'timestamp': '2024-01-15T10:30:00Z', + 'send_to': '/test/dictionary', + 'msg_purpose': 'test', + 'sender_name': 'mpy-test', + 'sender_id': _generate_uuid(), + 'receiver_name': 'mpy-receiver', + 'receiver_id': _generate_uuid(), + 'reply_to': '', + 'reply_to_msg_id': '', + 'broker_url': TEST_BROKER_URL, + 'metadata': {}, + 'payloads': [ + { + 'id': _generate_uuid(), + 'dataname': 'roundtrip', + 'payload_type': 'dictionary', + 'transport': 'direct', + 'encoding': 'base64', + 'size': len(json.dumps({'test': 'data', 'nested': {'a': 1, 'b': 2}}).encode('utf8')), + 'data': base64.b64encode(json.dumps({'test': 'data', 'nested': {'a': 1, 'b': 2}}).encode('utf8')).decode('ascii'), + 'metadata': {'payload_bytes': len(json.dumps({'test': 'data', 'nested': {'a': 1, 'b': 2}}).encode('utf8'))} + } + ] + } + + mock_rt_msg = {'payload': json.dumps(round_trip_data)} + rt_env = smartreceive(mock_rt_msg) + + if rt_env['payloads'][0][0] == 'roundtrip' and rt_env['payloads'][0][2] == 'dictionary': + print('✅ Round-trip test successful') + else: + print('❌ Round-trip test failed') + passed = False + + # Final result + print('\n=== Test Result ===') + if passed: + print('✅ ALL TESTS PASSED') + sys.exit(0) + else: + print('❌ SOME TESTS FAILED') + sys.exit(1) + + except Exception as e: + print(f'❌ Test failed with error: {e}') + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == '__main__': + run_test() \ No newline at end of file diff --git a/test/test_mpy_dictionary_sender.py b/test/test_mpy_dictionary_sender.py new file mode 100644 index 0000000..33aa92a --- /dev/null +++ b/test/test_mpy_dictionary_sender.py @@ -0,0 +1,177 @@ +""" +MicroPython Dictionary Sender Test +Tests the smartsend function with dictionary payloads + +Note: This test is designed for both MicroPython and desktop Python +for compatibility testing. +""" + +import sys +import os +import json + +# Add parent directory to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from natbridge_mpy import smartsend, DEFAULT_BROKER_URL, DEFAULT_FILESERVER_URL, DEFAULT_SIZE_THRESHOLD, MAX_PAYLOAD_SIZE + +TEST_SUBJECT = '/test/dictionary' +TEST_BROKER_URL = os.environ.get('NATS_URL', 'nats://localhost:4222') +TEST_FILESERVER_URL = os.environ.get('FILESERVER_URL', 'http://localhost:8080') + + +def run_test(): + print('=== MicroPython Dictionary Sender Test ===\n') + + from natbridge_mpy import _generate_uuid + correlation_id = 'mpy-dict-test-' + _generate_uuid() + print(f'Correlation ID: {correlation_id}') + print(f'Subject: {TEST_SUBJECT}') + print(f'Broker URL: {TEST_BROKER_URL}') + print(f'Default Size Threshold: {DEFAULT_SIZE_THRESHOLD} bytes') + print(f'Max Payload Size: {MAX_PAYLOAD_SIZE} bytes\n') + + # Test data - various dictionary structures + test_data = [ + ('simple_dict', {'key1': 'value1', 'key2': 'value2'}, 'dictionary'), + ('nested_dict', {'outer': {'inner': 'value', 'number': 42}}, 'dictionary'), + ('array_dict', {'items': [1, 2, 3, 'four', 'five']}, 'dictionary'), + ('mixed_dict', {'string': 'text', 'number': 123, 'boolean': True, 'null_val': None}, 'dictionary') + ] + + try: + # Send the message + print('Sending dictionary payloads...') + env, env_json_str = smartsend( + TEST_SUBJECT, + test_data, + broker_url=TEST_BROKER_URL, + fileserver_url=TEST_FILESERVER_URL, + correlation_id=correlation_id, + msg_purpose='test', + sender_name='mpy-dict-test', + is_publish=False + ) + + print('\n=== Envelope Created ===') + print(f'Correlation ID: {env["correlation_id"]}') + print(f'Message ID: {env["msg_id"]}') + print(f'Timestamp: {env["timestamp"]}') + print(f'Subject: {env["send_to"]}') + print(f'Purpose: {env["msg_purpose"]}') + print(f'Sender: {env["sender_name"]}') + print(f'Payloads: {len(env["payloads"])}\n') + + # Validate envelope structure + print('=== Validation ===') + passed = True + + if len(env['payloads']) != 4: + print(f'❌ Expected 4 payloads, got {len(env["payloads"])}') + passed = False + else: + print('✅ Correct number of payloads') + + # Test each payload + expected_datanames = ['simple_dict', 'nested_dict', 'array_dict', 'mixed_dict'] + expected_types = ['dictionary', 'dictionary', 'dictionary', 'dictionary'] + + for i in range(len(env['payloads'])): + payload = env['payloads'][i] + + if payload['dataname'] != expected_datanames[i]: + print(f"❌ Payload {i + 1}: Expected dataname '{expected_datanames[i]}', got '{payload['dataname']}'") + passed = False + else: + print(f'✅ Payload {i + 1}: Correct dataname') + + if payload['payload_type'] != expected_types[i]: + print(f"❌ Payload {i + 1}: Expected type '{expected_types[i]}', got '{payload['payload_type']}'") + passed = False + else: + print(f'✅ Payload {i + 1}: Correct type') + + if payload['transport'] != 'direct': + print(f"❌ Payload {i + 1}: Expected transport 'direct', got '{payload['transport']}'") + passed = False + else: + print(f'✅ Payload {i + 1}: Correct transport') + + if payload['encoding'] != 'base64': + print(f"❌ Payload {i + 1}: Expected encoding 'base64', got '{payload['encoding']}'") + passed = False + else: + print(f'✅ Payload {i + 1}: Correct encoding') + + # Decode and verify the data + import base64 + decoded_data = json.loads(base64.b64decode(payload['data']).decode('ascii')) + original_data = test_data[i][1] + + # Normalize for comparison + if json.dumps(decoded_data, sort_keys=True) != json.dumps(original_data, sort_keys=True): + print(f'❌ Payload {i + 1}: Data integrity mismatch') + print(f' Expected: {json.dumps(original_data)}') + print(f' Got: {json.dumps(decoded_data)}') + passed = False + else: + print(f'✅ Payload {i + 1}: Data integrity verified') + + print(f' Size: {payload["size"]} bytes\n') + + # Test round-trip serialization + print('=== Round-trip Serialization Test ===') + round_trip_data = [ + ('roundtrip', {'test': 'data', 'numbers': [1, 2, 3], 'nested': {'a': 1, 'b': 2}}, 'dictionary') + ] + + rt_env, _ = smartsend( + TEST_SUBJECT, + round_trip_data, + broker_url=TEST_BROKER_URL, + fileserver_url=TEST_FILESERVER_URL, + correlation_id='roundtrip-' + correlation_id, + is_publish=False + ) + + rt_payload = rt_env['payloads'][0] + rt_decoded = json.loads(base64.b64decode(rt_payload['data']).decode('ascii')) + + if json.dumps(rt_decoded, sort_keys=True) == json.dumps(round_trip_data[0][1], sort_keys=True): + print('✅ Round-trip serialization successful') + else: + print('❌ Round-trip serialization failed') + passed = False + + # Test JSON string output + print('\n=== JSON String Output Test ===') + try: + parsed = json.loads(env_json_str) + if parsed['correlation_id'] == env['correlation_id'] and \ + len(parsed['payloads']) == len(env['payloads']): + print('✅ JSON string is valid and matches envelope') + else: + print('❌ JSON string does not match envelope') + passed = False + except json.JSONDecodeError as e: + print(f'❌ JSON string is invalid: {e}') + passed = False + + # Final result + print('\n=== Test Result ===') + if passed: + print('✅ ALL TESTS PASSED') + sys.exit(0) + else: + print('❌ SOME TESTS FAILED') + sys.exit(1) + + except Exception as e: + print(f'❌ Test failed with error: {e}') + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == '__main__': + run_test() \ No newline at end of file diff --git a/test/test_mpy_text_receiver.py b/test/test_mpy_text_receiver.py new file mode 100644 index 0000000..c224d05 --- /dev/null +++ b/test/test_mpy_text_receiver.py @@ -0,0 +1,209 @@ +""" +MicroPython Text Receiver Test +Tests the smartreceive function with text payloads + +Note: This test is designed for both MicroPython and desktop Python +for compatibility testing. +""" + +import sys +import os +import json + +# Add parent directory to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from natbridge_mpy import smartreceive, DEFAULT_BROKER_URL, DEFAULT_FILESERVER_URL + +TEST_BROKER_URL = os.environ.get('NATS_URL', 'nats://localhost:4222') +TEST_FILESERVER_URL = os.environ.get('FILESERVER_URL', 'http://localhost:8080') + + +def run_test(): + print('=== MicroPython Text Receiver Test ===\n') + + from natbridge_mpy import _generate_uuid + + # Create a mock NATS message with text payload + test_text = 'Hello, NATSBridge! This is a test message.' + import base64 + + test_data = { + 'correlation_id': 'mpy-receiver-test-' + _generate_uuid(), + 'msg_id': _generate_uuid(), + 'timestamp': '2024-01-15T10:30:00Z', + 'send_to': '/test/text', + 'msg_purpose': 'test', + 'sender_name': 'mpy-text-test', + 'sender_id': _generate_uuid(), + 'receiver_name': 'mpy-receiver', + 'receiver_id': _generate_uuid(), + 'reply_to': '', + 'reply_to_msg_id': '', + 'broker_url': TEST_BROKER_URL, + 'metadata': {}, + 'payloads': [ + { + 'id': _generate_uuid(), + 'dataname': 'message', + 'payload_type': 'text', + 'transport': 'direct', + 'encoding': 'base64', + 'size': len(test_text.encode('utf8')), + 'data': base64.b64encode(test_text.encode('utf8')).decode('ascii'), + 'metadata': {'payload_bytes': len(test_text.encode('utf8'))} + } + ] + } + + mock_msg = { + 'payload': json.dumps(test_data) + } + + print('Mock Message Created:') + print(f' Correlation ID: {test_data["correlation_id"]}') + print(f' Payloads: {len(test_data["payloads"])}') + print(f' Payload dataname: {test_data["payloads"][0]["dataname"]}') + print(f' Payload type: {test_data["payloads"][0]["payload_type"]}') + print(f' Transport: {test_data["payloads"][0]["transport"]}\n') + + try: + # Receive and process the message + print('Receiving and processing message...') + env = smartreceive( + mock_msg, + max_retries=3, + base_delay=100, + max_delay=1000 + ) + + print('\n=== Received Envelope ===') + print(f'Correlation ID: {env["correlation_id"]}') + print(f'Message ID: {env["msg_id"]}') + print(f'Timestamp: {env["timestamp"]}') + print(f'Subject: {env["send_to"]}') + print(f'Payloads: {len(env["payloads"])}\n') + + # Validate received data + print('=== Validation ===') + passed = True + + if not env.get('correlation_id'): + print('❌ correlation_id is missing') + passed = False + else: + print('✅ correlation_id present') + + if len(env['payloads']) != 1: + print(f'❌ Expected 1 payload, got {len(env["payloads"])}') + passed = False + else: + print('✅ Correct number of payloads') + + payload = env['payloads'][0] + if payload[0] != 'message': + print(f"❌ Expected dataname 'message', got '{payload[0]}'") + passed = False + else: + print('✅ Correct dataname') + + if payload[2] != 'text': + print(f"❌ Expected type 'text', got '{payload[2]}'") + passed = False + else: + print('✅ Correct type') + + if payload[1] != test_text: + print('❌ Data mismatch') + print(f' Expected: {test_text}') + print(f' Got: {payload[1]}') + passed = False + else: + print('✅ Data correctly deserialized') + + # Test with multiple text payloads + print('\n=== Multiple Text Payloads Test ===') + multi_test_data = { + 'correlation_id': 'multi-receiver-' + _generate_uuid(), + 'msg_id': _generate_uuid(), + 'timestamp': '2024-01-15T10:30:00Z', + 'send_to': '/test/text', + 'msg_purpose': 'test', + 'sender_name': 'mpy-text-test', + 'sender_id': _generate_uuid(), + 'receiver_name': 'mpy-receiver', + 'receiver_id': _generate_uuid(), + 'reply_to': '', + 'reply_to_msg_id': '', + 'broker_url': TEST_BROKER_URL, + 'metadata': {}, + 'payloads': [ + { + 'id': _generate_uuid(), + 'dataname': 'msg1', + 'payload_type': 'text', + 'transport': 'direct', + 'encoding': 'base64', + 'size': 16, + 'data': base64.b64encode(b'First message').decode('ascii'), + 'metadata': {'payload_bytes': 16} + }, + { + 'id': _generate_uuid(), + 'dataname': 'msg2', + 'payload_type': 'text', + 'transport': 'direct', + 'encoding': 'base64', + 'size': 16, + 'data': base64.b64encode(b'Second message').decode('ascii'), + 'metadata': {'payload_bytes': 16} + }, + { + 'id': _generate_uuid(), + 'dataname': 'msg3', + 'payload_type': 'text', + 'transport': 'direct', + 'encoding': 'base64', + 'size': 16, + 'data': base64.b64encode(b'Third message').decode('ascii'), + 'metadata': {'payload_bytes': 16} + } + ] + } + + mock_multi_msg = {'payload': json.dumps(multi_test_data)} + multi_env = smartreceive(mock_multi_msg) + + if len(multi_env['payloads']) == 3: + print('✅ Multiple payloads handled correctly') + + # Verify each payload + expected_messages = ['First message', 'Second message', 'Third message'] + for i in range(3): + if multi_env['payloads'][i][1] == expected_messages[i]: + print(f'✅ Payload {i + 1} correctly deserialized') + else: + print(f'❌ Payload {i + 1} mismatch') + passed = False + else: + print(f'❌ Expected 3 payloads, got {len(multi_env["payloads"])}') + passed = False + + # Final result + print('\n=== Test Result ===') + if passed: + print('✅ ALL TESTS PASSED') + sys.exit(0) + else: + print('❌ SOME TESTS FAILED') + sys.exit(1) + + except Exception as e: + print(f'❌ Test failed with error: {e}') + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == '__main__': + run_test() \ No newline at end of file diff --git a/test/test_mpy_text_sender.py b/test/test_mpy_text_sender.py new file mode 100644 index 0000000..f52fd4f --- /dev/null +++ b/test/test_mpy_text_sender.py @@ -0,0 +1,205 @@ +""" +MicroPython Text Sender Test +Tests the smartsend function with text payloads + +Note: This test is designed for both MicroPython and desktop Python +for compatibility testing. +""" + +import sys +import os + +# Add parent directory to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from natbridge_mpy import smartsend, DEFAULT_BROKER_URL, DEFAULT_FILESERVER_URL, DEFAULT_SIZE_THRESHOLD, MAX_PAYLOAD_SIZE + +TEST_SUBJECT = '/test/text' +TEST_BROKER_URL = os.environ.get('NATS_URL', 'nats://localhost:4222') +TEST_FILESERVER_URL = os.environ.get('FILESERVER_URL', 'http://localhost:8080') + + +def run_test(): + print('=== MicroPython Text Sender Test ===\n') + + from natbridge_mpy import _generate_uuid + correlation_id = 'mpy-text-test-' + _generate_uuid() + print(f'Correlation ID: {correlation_id}') + print(f'Subject: {TEST_SUBJECT}') + print(f'Broker URL: {TEST_BROKER_URL}') + print(f'Default Size Threshold: {DEFAULT_SIZE_THRESHOLD} bytes') + print(f'Max Payload Size: {MAX_PAYLOAD_SIZE} bytes\n') + + # Test data + text_data = 'Hello, NATSBridge! This is a test message.' + test_data = [ + ('message', text_data, 'text') + ] + + try: + # Send the message + print('Sending text payload...') + env, env_json_str = smartsend( + TEST_SUBJECT, + test_data, + broker_url=TEST_BROKER_URL, + fileserver_url=TEST_FILESERVER_URL, + correlation_id=correlation_id, + msg_purpose='test', + sender_name='mpy-text-test', + is_publish=False # Don't actually publish for this test + ) + + print('\n=== Envelope Created ===') + print(f'Correlation ID: {env["correlation_id"]}') + print(f'Message ID: {env["msg_id"]}') + print(f'Timestamp: {env["timestamp"]}') + print(f'Subject: {env["send_to"]}') + print(f'Purpose: {env["msg_purpose"]}') + print(f'Sender: {env["sender_name"]}') + print(f'Payloads: {len(env["payloads"])}\n') + + # Validate envelope structure + print('=== Validation ===') + passed = True + + if not env.get('correlation_id'): + print('❌ correlation_id is missing') + passed = False + else: + print('✅ correlation_id present') + + if not env.get('msg_id'): + print('❌ msg_id is missing') + passed = False + else: + print('✅ msg_id present') + + if not env.get('timestamp'): + print('❌ timestamp is missing') + passed = False + else: + print('✅ timestamp present') + + if len(env['payloads']) != 1: + print(f'❌ Expected 1 payload, got {len(env["payloads"])}') + passed = False + else: + print('✅ Correct number of payloads') + + payload = env['payloads'][0] + if payload['dataname'] != 'message': + print(f"❌ Expected dataname 'message', got '{payload['dataname']}'") + passed = False + else: + print('✅ Correct dataname') + + if payload['payload_type'] != 'text': + print(f"❌ Expected payload_type 'text', got '{payload['payload_type']}'") + passed = False + else: + print('✅ Correct payload_type') + + if payload['transport'] != 'direct': + print(f"❌ Expected transport 'direct', got '{payload['transport']}'") + passed = False + else: + print('✅ Correct transport') + + if payload['encoding'] != 'base64': + print(f"❌ Expected encoding 'base64', got '{payload['encoding']}'") + passed = False + else: + print('✅ Correct encoding') + + # Decode and verify the data + import base64 + decoded_data = base64.b64decode(payload['data']).decode('ascii') + if decoded_data != text_data: + print('❌ Decoded data mismatch') + print(f' Expected: {text_data}') + print(f' Got: {decoded_data}') + passed = False + else: + print('✅ Data integrity verified') + + print(f'\nPayload size: {payload["size"]} bytes') + print(f'Base64 data length: {len(payload["data"])} chars') + + # Test with multiple text payloads + print('\n=== Multiple Text Payloads Test ===') + multi_test_data = [ + ('msg1', 'First message', 'text'), + ('msg2', 'Second message', 'text'), + ('msg3', 'Third message', 'text') + ] + + multi_env, _ = smartsend( + TEST_SUBJECT, + multi_test_data, + broker_url=TEST_BROKER_URL, + fileserver_url=TEST_FILESERVER_URL, + correlation_id='multi-test-' + correlation_id, + is_publish=False + ) + + if len(multi_env['payloads']) == 3: + print('✅ Multiple payloads handled correctly') + else: + print(f'❌ Expected 3 payloads, got {len(multi_env["payloads"])}') + passed = False + + # Test size threshold enforcement + print('\n=== Size Threshold Test ===') + small_text = 'small' + large_text = 'x' * (DEFAULT_SIZE_THRESHOLD - 100) # Just under threshold + + small_env, _ = smartsend( + TEST_SUBJECT, + [('small', small_text, 'text')], + broker_url=TEST_BROKER_URL, + is_publish=False + ) + + if small_env['payloads'][0]['transport'] == 'direct': + print('✅ Small payload uses direct transport') + else: + print('❌ Small payload should use direct transport') + passed = False + + # Test that large text (> MAX_PAYLOAD_SIZE) raises error + print('\n=== Max Payload Size Test ===') + try: + too_large_text = 'x' * (MAX_PAYLOAD_SIZE + 1000) + large_env, _ = smartsend( + TEST_SUBJECT, + [('large', too_large_text, 'text')], + broker_url=TEST_BROKER_URL, + is_publish=False + ) + print('❌ Should have raised MemoryError for payload exceeding MAX_PAYLOAD_SIZE') + passed = False + except MemoryError as e: + print(f'✅ Correctly raised MemoryError: {e}') + except Exception as e: + print(f'❌ Unexpected error: {type(e).__name__}: {e}') + passed = False + + # Final result + print('\n=== Test Result ===') + if passed: + print('✅ ALL TESTS PASSED') + sys.exit(0) + else: + print('❌ SOME TESTS FAILED') + sys.exit(1) + + except Exception as e: + print(f'❌ Test failed with error: {e}') + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == '__main__': + run_test() \ No newline at end of file diff --git a/test/test_py_binary_receiver.py b/test/test_py_binary_receiver.py new file mode 100644 index 0000000..d1410b0 --- /dev/null +++ b/test/test_py_binary_receiver.py @@ -0,0 +1,184 @@ +""" +Python Binary Receiver Test +Tests the smartreceive function with binary/image/audio/video/table payloads +""" + +import asyncio +import sys +import os +import json +import base64 + +# Add parent directory to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from natbridge import smartreceive, DEFAULT_BROKER_URL, DEFAULT_FILESERVER_URL + +TEST_BROKER_URL = os.environ.get('NATS_URL', 'nats://localhost:4222') +TEST_FILESERVER_URL = os.environ.get('FILESERVER_URL', 'http://localhost:8080') + + +async def run_test(): + print('=== Python Binary Receiver Test ===\n') + + # Create mock NATS message with binary payloads + image_data = bytes([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) # PNG header + audio_data = bytes([0x46, 0x4C, 0x41, 0x43, 0x00, 0x00, 0x00, 0x00]) # FLAC header + video_data = bytes([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70]) # MP4 header + generic_binary = bytes([0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE]) + + test_data = { + 'correlation_id': 'py-binary-receiver-' + str(asyncio.get_event_loop().time() * 1000000), + 'msg_id': 'msg-' + str(asyncio.get_event_loop().time() * 1000000), + 'timestamp': asyncio.get_event_loop().time().isoformat(), + 'send_to': '/test/binary', + 'msg_purpose': 'test', + 'sender_name': 'py-binary-test', + 'sender_id': 'sender-' + str(asyncio.get_event_loop().time() * 1000000), + 'receiver_name': 'py-receiver', + 'receiver_id': 'receiver-' + str(asyncio.get_event_loop().time() * 1000000), + 'reply_to': '', + 'reply_to_msg_id': '', + 'broker_url': TEST_BROKER_URL, + 'metadata': {}, + 'payloads': [ + { + 'id': 'payload-1', + 'dataname': 'image', + 'payload_type': 'image', + 'transport': 'direct', + 'encoding': 'base64', + 'size': len(image_data), + 'data': base64.b64encode(image_data).decode('ascii'), + 'metadata': {'payload_bytes': len(image_data)} + }, + { + 'id': 'payload-2', + 'dataname': 'audio', + 'payload_type': 'audio', + 'transport': 'direct', + 'encoding': 'base64', + 'size': len(audio_data), + 'data': base64.b64encode(audio_data).decode('ascii'), + 'metadata': {'payload_bytes': len(audio_data)} + }, + { + 'id': 'payload-3', + 'dataname': 'video', + 'payload_type': 'video', + 'transport': 'direct', + 'encoding': 'base64', + 'size': len(video_data), + 'data': base64.b64encode(video_data).decode('ascii'), + 'metadata': {'payload_bytes': len(video_data)} + }, + { + 'id': 'payload-4', + 'dataname': 'binary', + 'payload_type': 'binary', + 'transport': 'direct', + 'encoding': 'base64', + 'size': len(generic_binary), + 'data': base64.b64encode(generic_binary).decode('ascii'), + 'metadata': {'payload_bytes': len(generic_binary)} + } + ] + } + + mock_msg = { + 'payload': json.dumps(test_data) + } + + print('Mock Message Created:') + print(f' Correlation ID: {test_data["correlation_id"]}') + print(f' Payloads: {len(test_data["payloads"])}') + print(f' Payload types: {", ".join(p["payload_type"] for p in test_data["payloads"])}\n') + + try: + # Receive and process the message + print('Receiving and processing message...') + env = await smartreceive( + mock_msg, + max_retries=3, + base_delay=100, + max_delay=1000 + ) + + print('\n=== Received Envelope ===') + print(f'Correlation ID: {env["correlation_id"]}') + print(f'Message ID: {env["msg_id"]}') + print(f'Timestamp: {env["timestamp"]}') + print(f'Subject: {env["send_to"]}') + print(f'Payloads: {len(env["payloads"])}\n') + + # Validate received data + print('=== Validation ===') + passed = True + + if not env.get('correlation_id'): + print('❌ correlation_id is missing') + passed = False + else: + print('✅ correlation_id present') + + if len(env['payloads']) != 4: + print(f'❌ Expected 4 payloads, got {len(env["payloads"])}') + passed = False + else: + print('✅ Correct number of payloads') + + # Expected data + expected_data = [ + ('image', image_data, 'image'), + ('audio', audio_data, 'audio'), + ('video', video_data, 'video'), + ('binary', generic_binary, 'binary') + ] + + for i in range(len(env['payloads'])): + payload = env['payloads'][i] + expected = expected_data[i] + + if payload[0] != expected[0]: + print(f"❌ Payload {i + 1}: Expected dataname '{expected[0]}', got '{payload[0]}'") + passed = False + else: + print(f'✅ Payload {i + 1}: Correct dataname') + + if payload[2] != expected[2]: + print(f"❌ Payload {i + 1}: Expected type '{expected[2]}', got '{payload[2]}'") + passed = False + else: + print(f'✅ Payload {i + 1}: Correct type') + + # Verify binary data integrity + received_data = payload[1] + if not isinstance(received_data, (bytes, bytearray)): + print(f'❌ Payload {i + 1}: Expected bytes/bytearray, got {type(received_data)}') + passed = False + elif received_data != expected[1]: + print(f'❌ Payload {i + 1}: Data mismatch') + print(f' Expected: {expected[1]}') + print(f' Got: {received_data}') + passed = False + else: + print(f'✅ Payload {i + 1}: Data correctly deserialized') + + # Final result + print('\n=== Test Result ===') + if passed: + print('✅ ALL TESTS PASSED') + sys.exit(0) + else: + print('❌ SOME TESTS FAILED') + sys.exit(1) + + except Exception as e: + print(f'❌ Test failed with error: {e}') + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == '__main__': + asyncio.run(run_test()) \ No newline at end of file diff --git a/test/test_py_binary_sender.py b/test/test_py_binary_sender.py new file mode 100644 index 0000000..2de616d --- /dev/null +++ b/test/test_py_binary_sender.py @@ -0,0 +1,183 @@ +""" +Python Binary Sender Test +Tests the smartsend function with binary/image/audio/video/table payloads +""" + +import asyncio +import sys +import os +import base64 + +# Add parent directory to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from natbridge import smartsend, DEFAULT_BROKER_URL, DEFAULT_FILESERVER_URL + +TEST_SUBJECT = '/test/binary' +TEST_BROKER_URL = os.environ.get('NATS_URL', 'nats://localhost:4222') +TEST_FILESERVER_URL = os.environ.get('FILESERVER_URL', 'http://localhost:8080') + + +async def run_test(): + print('=== Python Binary Sender Test ===\n') + + correlation_id = 'py-binary-test-' + str(asyncio.get_event_loop().time() * 1000000) + print(f'Correlation ID: {correlation_id}') + print(f'Subject: {TEST_SUBJECT}') + print(f'Broker URL: {TEST_BROKER_URL}\n') + + # Test data - binary data for different types + image_data = bytes([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) # PNG header + audio_data = bytes([0x46, 0x4C, 0x41, 0x43, 0x00, 0x00, 0x00, 0x00]) # FLAC header + video_data = bytes([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70]) # MP4 header + generic_binary = bytes([0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE]) + + # Test table data + try: + import pandas as pd + table_data = pd.DataFrame({ + 'id': [1, 2, 3, 4, 5], + 'name': ['Alice', 'Bob', 'Charlie', 'David', 'Eve'], + 'value': [10.5, 20.3, 30.1, 40.9, 50.7] + }) + table_available = True + except ImportError: + table_available = False + table_data = None + + test_data = [ + ('image', image_data, 'image'), + ('audio', audio_data, 'audio'), + ('video', video_data, 'video'), + ('binary', generic_binary, 'binary') + ] + + if table_available: + test_data.append(('table', table_data, 'table')) + + try: + # Send the message + print('Sending binary payloads...') + env, env_json_str = await smartsend( + TEST_SUBJECT, + test_data, + broker_url=TEST_BROKER_URL, + fileserver_url=TEST_FILESERVER_URL, + correlation_id=correlation_id, + msg_purpose='test', + sender_name='py-binary-test', + is_publish=False + ) + + print('\n=== Envelope Created ===') + print(f'Correlation ID: {env["correlation_id"]}') + print(f'Message ID: {env["msg_id"]}') + print(f'Timestamp: {env["timestamp"]}') + print(f'Subject: {env["send_to"]}') + print(f'Purpose: {env["msg_purpose"]}') + print(f'Sender: {env["sender_name"]}') + print(f'Payloads: {len(env["payloads"])}\n') + + # Validate envelope structure + print('=== Validation ===') + passed = True + + expected_count = 5 if table_available else 4 + if len(env['payloads']) != expected_count: + print(f'❌ Expected {expected_count} payloads, got {len(env["payloads"])}') + passed = False + else: + print('✅ Correct number of payloads') + + # Test each payload + expected_datanames = ['image', 'audio', 'video', 'binary'] + expected_types = ['image', 'audio', 'video', 'binary'] + expected_data = [image_data, audio_data, video_data, generic_binary] + + if table_available: + expected_datanames.append('table') + expected_types.append('table') + + for i in range(len(env['payloads'])): + payload = env['payloads'][i] + + if payload['dataname'] != expected_datanames[i]: + print(f"❌ Payload {i + 1}: Expected dataname '{expected_datanames[i]}', got '{payload['dataname']}'") + passed = False + else: + print(f'✅ Payload {i + 1}: Correct dataname') + + if payload['payload_type'] != expected_types[i]: + print(f"❌ Payload {i + 1}: Expected type '{expected_types[i]}', got '{payload['payload_type']}'") + passed = False + else: + print(f'✅ Payload {i + 1}: Correct type') + + if payload['transport'] != 'direct': + print(f"❌ Payload {i + 1}: Expected transport 'direct', got '{payload['transport']}'") + passed = False + else: + print(f'✅ Payload {i + 1}: Correct transport') + + if payload['encoding'] != 'base64': + print(f"❌ Payload {i + 1}: Expected encoding 'base64', got '{payload['encoding']}'") + passed = False + else: + print(f'✅ Payload {i + 1}: Correct encoding') + + # Decode and verify the data + decoded_data = base64.b64decode(payload['data']) + + if i < len(expected_data): + original_data = expected_data[i] + if decoded_data != original_data: + print(f'❌ Payload {i + 1}: Data integrity mismatch') + passed = False + else: + print(f'✅ Payload {i + 1}: Data integrity verified') + else: + # Table payload - just verify it's present + print(f'✅ Payload {i + 1}: Table data present (size: {payload["size"]} bytes)') + + print(f' Size: {payload["size"]} bytes\n') + + # Test with larger binary data + print('=== Large Binary Data Test ===') + large_data = bytes([0xFF] * 10000) # 10KB of binary data + large_test_data = [ + ('large_binary', large_data, 'binary') + ] + + large_env, _ = await smartsend( + TEST_SUBJECT, + large_test_data, + broker_url=TEST_BROKER_URL, + fileserver_url=TEST_FILESERVER_URL, + correlation_id='large-' + correlation_id, + is_publish=False + ) + + if len(large_env['payloads']) == 1 and large_env['payloads'][0]['size'] == 10000: + print('✅ Large binary data handled correctly') + else: + print('❌ Large binary data handling failed') + passed = False + + # Final result + print('\n=== Test Result ===') + if passed: + print('✅ ALL TESTS PASSED') + sys.exit(0) + else: + print('❌ SOME TESTS FAILED') + sys.exit(1) + + except Exception as e: + print(f'❌ Test failed with error: {e}') + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == '__main__': + asyncio.run(run_test()) \ No newline at end of file diff --git a/test/test_py_dictionary_receiver.py b/test/test_py_dictionary_receiver.py new file mode 100644 index 0000000..f6afb16 --- /dev/null +++ b/test/test_py_dictionary_receiver.py @@ -0,0 +1,220 @@ +""" +Python Dictionary Receiver Test +Tests the smartreceive function with dictionary payloads +""" + +import asyncio +import sys +import os +import json + +# Add parent directory to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from natbridge import smartreceive, DEFAULT_BROKER_URL, DEFAULT_FILESERVER_URL + +TEST_BROKER_URL = os.environ.get('NATS_URL', 'nats://localhost:4222') +TEST_FILESERVER_URL = os.environ.get('FILESERVER_URL', 'http://localhost:8080') + + +async def run_test(): + print('=== Python Dictionary Receiver Test ===\n') + + # Create a mock NATS message with dictionary payloads + import base64 + + simple_dict = {'key1': 'value1', 'key2': 'value2'} + nested_dict = {'outer': {'inner': 'value', 'number': 42}} + array_dict = {'items': [1, 2, 3, 'four', 'five']} + mixed_dict = {'string': 'text', 'number': 123, 'boolean': True, 'null_val': None} + + test_data = { + 'correlation_id': 'py-receiver-dict-' + str(asyncio.get_event_loop().time() * 1000000), + 'msg_id': 'msg-' + str(asyncio.get_event_loop().time() * 1000000), + 'timestamp': asyncio.get_event_loop().time().isoformat(), + 'send_to': '/test/dictionary', + 'msg_purpose': 'test', + 'sender_name': 'py-dict-test', + 'sender_id': 'sender-' + str(asyncio.get_event_loop().time() * 1000000), + 'receiver_name': 'py-receiver', + 'receiver_id': 'receiver-' + str(asyncio.get_event_loop().time() * 1000000), + 'reply_to': '', + 'reply_to_msg_id': '', + 'broker_url': TEST_BROKER_URL, + 'metadata': {}, + 'payloads': [ + { + 'id': 'payload-1', + 'dataname': 'simple_dict', + 'payload_type': 'dictionary', + 'transport': 'direct', + 'encoding': 'base64', + 'size': len(json.dumps(simple_dict).encode('utf8')), + 'data': base64.b64encode(json.dumps(simple_dict).encode('utf8')).decode('ascii'), + 'metadata': {'payload_bytes': len(json.dumps(simple_dict).encode('utf8'))} + }, + { + 'id': 'payload-2', + 'dataname': 'nested_dict', + 'payload_type': 'dictionary', + 'transport': 'direct', + 'encoding': 'base64', + 'size': len(json.dumps(nested_dict).encode('utf8')), + 'data': base64.b64encode(json.dumps(nested_dict).encode('utf8')).decode('ascii'), + 'metadata': {'payload_bytes': len(json.dumps(nested_dict).encode('utf8'))} + }, + { + 'id': 'payload-3', + 'dataname': 'array_dict', + 'payload_type': 'dictionary', + 'transport': 'direct', + 'encoding': 'base64', + 'size': len(json.dumps(array_dict).encode('utf8')), + 'data': base64.b64encode(json.dumps(array_dict).encode('utf8')).decode('ascii'), + 'metadata': {'payload_bytes': len(json.dumps(array_dict).encode('utf8'))} + }, + { + 'id': 'payload-4', + 'dataname': 'mixed_dict', + 'payload_type': 'dictionary', + 'transport': 'direct', + 'encoding': 'base64', + 'size': len(json.dumps(mixed_dict).encode('utf8')), + 'data': base64.b64encode(json.dumps(mixed_dict).encode('utf8')).decode('ascii'), + 'metadata': {'payload_bytes': len(json.dumps(mixed_dict).encode('utf8'))} + } + ] + } + + mock_msg = { + 'payload': json.dumps(test_data) + } + + print('Mock Message Created:') + print(f' Correlation ID: {test_data["correlation_id"]}') + print(f' Payloads: {len(test_data["payloads"])}') + print(f' Payload types: {", ".join(p["payload_type"] for p in test_data["payloads"])}\n') + + try: + # Receive and process the message + print('Receiving and processing message...') + env = await smartreceive( + mock_msg, + max_retries=3, + base_delay=100, + max_delay=1000 + ) + + print('\n=== Received Envelope ===') + print(f'Correlation ID: {env["correlation_id"]}') + print(f'Message ID: {env["msg_id"]}') + print(f'Timestamp: {env["timestamp"]}') + print(f'Subject: {env["send_to"]}') + print(f'Payloads: {len(env["payloads"])}\n') + + # Validate received data + print('=== Validation ===') + passed = True + + if not env.get('correlation_id'): + print('❌ correlation_id is missing') + passed = False + else: + print('✅ correlation_id present') + + if len(env['payloads']) != 4: + print(f'❌ Expected 4 payloads, got {len(env["payloads"])}') + passed = False + else: + print('✅ Correct number of payloads') + + # Expected data + expected_data = [ + ('simple_dict', simple_dict, 'dictionary'), + ('nested_dict', nested_dict, 'dictionary'), + ('array_dict', array_dict, 'dictionary'), + ('mixed_dict', mixed_dict, 'dictionary') + ] + + for i in range(len(env['payloads'])): + payload = env['payloads'][i] + expected = expected_data[i] + + if payload[0] != expected[0]: + print(f"❌ Payload {i + 1}: Expected dataname '{expected[0]}', got '{payload[0]}'") + passed = False + else: + print(f'✅ Payload {i + 1}: Correct dataname') + + if payload[2] != expected[2]: + print(f"❌ Payload {i + 1}: Expected type '{expected[2]}', got '{payload[2]}'") + passed = False + else: + print(f'✅ Payload {i + 1}: Correct type') + + data_match = json.dumps(payload[1], sort_keys=True) == json.dumps(expected[1], sort_keys=True) + if not data_match: + print(f'❌ Payload {i + 1}: Data mismatch') + print(f' Expected: {json.dumps(expected[1], sort_keys=True)}') + print(f' Got: {json.dumps(payload[1], sort_keys=True)}') + passed = False + else: + print(f'✅ Payload {i + 1}: Data correctly deserialized') + + # Test round-trip with receive + print('\n=== Round-trip Test ===') + round_trip_data = { + 'correlation_id': 'roundtrip-' + str(asyncio.get_event_loop().time() * 1000000), + 'msg_id': 'msg-' + str(asyncio.get_event_loop().time() * 1000000), + 'timestamp': asyncio.get_event_loop().time().isoformat(), + 'send_to': '/test/dictionary', + 'msg_purpose': 'test', + 'sender_name': 'py-test', + 'sender_id': 'sender-' + str(asyncio.get_event_loop().time() * 1000000), + 'receiver_name': 'py-receiver', + 'receiver_id': 'receiver-' + str(asyncio.get_event_loop().time() * 1000000), + 'reply_to': '', + 'reply_to_msg_id': '', + 'broker_url': TEST_BROKER_URL, + 'metadata': {}, + 'payloads': [ + { + 'id': 'payload-rt', + 'dataname': 'roundtrip', + 'payload_type': 'dictionary', + 'transport': 'direct', + 'encoding': 'base64', + 'size': len(json.dumps({'test': 'data', 'nested': {'a': 1, 'b': 2}}).encode('utf8')), + 'data': base64.b64encode(json.dumps({'test': 'data', 'nested': {'a': 1, 'b': 2}}).encode('utf8')).decode('ascii'), + 'metadata': {'payload_bytes': len(json.dumps({'test': 'data', 'nested': {'a': 1, 'b': 2}}).encode('utf8'))} + } + ] + } + + mock_rt_msg = {'payload': json.dumps(round_trip_data)} + rt_env = await smartreceive(mock_rt_msg) + + if rt_env['payloads'][0][0] == 'roundtrip' and rt_env['payloads'][0][2] == 'dictionary': + print('✅ Round-trip test successful') + else: + print('❌ Round-trip test failed') + passed = False + + # Final result + print('\n=== Test Result ===') + if passed: + print('✅ ALL TESTS PASSED') + sys.exit(0) + else: + print('❌ SOME TESTS FAILED') + sys.exit(1) + + except Exception as e: + print(f'❌ Test failed with error: {e}') + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == '__main__': + asyncio.run(run_test()) \ No newline at end of file diff --git a/test/test_py_dictionary_sender.py b/test/test_py_dictionary_sender.py new file mode 100644 index 0000000..908c2e7 --- /dev/null +++ b/test/test_py_dictionary_sender.py @@ -0,0 +1,172 @@ +""" +Python Dictionary Sender Test +Tests the smartsend function with dictionary payloads +""" + +import asyncio +import sys +import os +import json + +# Add parent directory to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from natbridge import smartsend, DEFAULT_BROKER_URL, DEFAULT_FILESERVER_URL + +TEST_SUBJECT = '/test/dictionary' +TEST_BROKER_URL = os.environ.get('NATS_URL', 'nats://localhost:4222') +TEST_FILESERVER_URL = os.environ.get('FILESERVER_URL', 'http://localhost:8080') + + +async def run_test(): + print('=== Python Dictionary Sender Test ===\n') + + correlation_id = 'py-dict-test-' + str(asyncio.get_event_loop().time() * 1000000) + print(f'Correlation ID: {correlation_id}') + print(f'Subject: {TEST_SUBJECT}') + print(f'Broker URL: {TEST_BROKER_URL}\n') + + # Test data - various dictionary structures + test_data = [ + ('simple_dict', {'key1': 'value1', 'key2': 'value2'}, 'dictionary'), + ('nested_dict', {'outer': {'inner': 'value', 'number': 42}}, 'dictionary'), + ('array_dict', {'items': [1, 2, 3, 'four', 'five']}, 'dictionary'), + ('mixed_dict', {'string': 'text', 'number': 123, 'boolean': True, 'null_val': None}, 'dictionary') + ] + + try: + # Send the message + print('Sending dictionary payloads...') + env, env_json_str = await smartsend( + TEST_SUBJECT, + test_data, + broker_url=TEST_BROKER_URL, + fileserver_url=TEST_FILESERVER_URL, + correlation_id=correlation_id, + msg_purpose='test', + sender_name='py-dict-test', + is_publish=False + ) + + print('\n=== Envelope Created ===') + print(f'Correlation ID: {env["correlation_id"]}') + print(f'Message ID: {env["msg_id"]}') + print(f'Timestamp: {env["timestamp"]}') + print(f'Subject: {env["send_to"]}') + print(f'Purpose: {env["msg_purpose"]}') + print(f'Sender: {env["sender_name"]}') + print(f'Payloads: {len(env["payloads"])}\n') + + # Validate envelope structure + print('=== Validation ===') + passed = True + + if len(env['payloads']) != 4: + print(f'❌ Expected 4 payloads, got {len(env["payloads"])}') + passed = False + else: + print('✅ Correct number of payloads') + + # Test each payload + expected_datanames = ['simple_dict', 'nested_dict', 'array_dict', 'mixed_dict'] + expected_types = ['dictionary', 'dictionary', 'dictionary', 'dictionary'] + + for i in range(len(env['payloads'])): + payload = env['payloads'][i] + + if payload['dataname'] != expected_datanames[i]: + print(f"❌ Payload {i + 1}: Expected dataname '{expected_datanames[i]}', got '{payload['dataname']}'") + passed = False + else: + print(f'✅ Payload {i + 1}: Correct dataname') + + if payload['payload_type'] != expected_types[i]: + print(f"❌ Payload {i + 1}: Expected type '{expected_types[i]}', got '{payload['payload_type']}'") + passed = False + else: + print(f'✅ Payload {i + 1}: Correct type') + + if payload['transport'] != 'direct': + print(f"❌ Payload {i + 1}: Expected transport 'direct', got '{payload['transport']}'") + passed = False + else: + print(f'✅ Payload {i + 1}: Correct transport') + + if payload['encoding'] != 'base64': + print(f"❌ Payload {i + 1}: Expected encoding 'base64', got '{payload['encoding']}'") + passed = False + else: + print(f'✅ Payload {i + 1}: Correct encoding') + + # Decode and verify the data + import base64 + decoded_data = json.loads(base64.b64decode(payload['data']).decode('utf8')) + original_data = test_data[i][1] + + # Normalize for comparison (None vs null, True vs true, etc.) + if json.dumps(decoded_data, sort_keys=True) != json.dumps(original_data, sort_keys=True): + print(f'❌ Payload {i + 1}: Data integrity mismatch') + print(f' Expected: {json.dumps(original_data)}') + print(f' Got: {json.dumps(decoded_data)}') + passed = False + else: + print(f'✅ Payload {i + 1}: Data integrity verified') + + print(f' Size: {payload["size"]} bytes\n') + + # Test round-trip serialization + print('=== Round-trip Serialization Test ===') + round_trip_data = [ + ('roundtrip', {'test': 'data', 'numbers': [1, 2, 3], 'nested': {'a': 1, 'b': 2}}, 'dictionary') + ] + + rt_env, _ = await smartsend( + TEST_SUBJECT, + round_trip_data, + broker_url=TEST_BROKER_URL, + fileserver_url=TEST_FILESERVER_URL, + correlation_id='roundtrip-' + correlation_id, + is_publish=False + ) + + rt_payload = rt_env['payloads'][0] + rt_decoded = json.loads(base64.b64decode(rt_payload['data']).decode('utf8')) + + if json.dumps(rt_decoded, sort_keys=True) == json.dumps(round_trip_data[0][1], sort_keys=True): + print('✅ Round-trip serialization successful') + else: + print('❌ Round-trip serialization failed') + passed = False + + # Test JSON string output + print('\n=== JSON String Output Test ===') + try: + parsed = json.loads(env_json_str) + if parsed['correlation_id'] == env['correlation_id'] and \ + len(parsed['payloads']) == len(env['payloads']): + print('✅ JSON string is valid and matches envelope') + else: + print('❌ JSON string does not match envelope') + passed = False + except json.JSONDecodeError as e: + print(f'❌ JSON string is invalid: {e}') + passed = False + + # Final result + print('\n=== Test Result ===') + if passed: + print('✅ ALL TESTS PASSED') + sys.exit(0) + else: + print('❌ SOME TESTS FAILED') + sys.exit(1) + + except Exception as e: + print(f'❌ Test failed with error: {e}') + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == '__main__': + asyncio.run(run_test()) \ No newline at end of file diff --git a/test/test_py_mix_payloads_sender.py b/test/test_py_mix_payloads_sender.py new file mode 100644 index 0000000..3a95e0c --- /dev/null +++ b/test/test_py_mix_payloads_sender.py @@ -0,0 +1,199 @@ +""" +Python Mix Payloads Sender Test +Tests the smartsend function with mixed payload types +""" + +import asyncio +import sys +import os +import base64 + +# Add parent directory to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from natbridge import smartsend, DEFAULT_BROKER_URL, DEFAULT_FILESERVER_URL + +TEST_SUBJECT = '/test/mix' +TEST_BROKER_URL = os.environ.get('NATS_URL', 'nats://localhost:4222') +TEST_FILESERVER_URL = os.environ.get('FILESERVER_URL', 'http://localhost:8080') + + +async def run_test(): + print('=== Python Mix Payloads Sender Test ===\n') + + correlation_id = 'py-mix-test-' + str(asyncio.get_event_loop().time() * 1000000) + print(f'Correlation ID: {correlation_id}') + print(f'Subject: {TEST_SUBJECT}') + print(f'Broker URL: {TEST_BROKER_URL}\n') + + # Test data - mixed payload types + text_data = 'Hello, NATSBridge!' + dict_data = {'key1': 'value1', 'key2': 42, 'nested': {'a': 1, 'b': 2}} + image_data = bytes([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) # PNG header + + # Table data + try: + import pandas as pd + table_data = pd.DataFrame({ + 'id': [1, 2, 3], + 'name': ['Alice', 'Bob', 'Charlie'], + 'age': [30, 25, 35] + }) + table_available = True + except ImportError: + table_available = False + table_data = None + + test_data = [ + ('message', text_data, 'text'), + ('config', dict_data, 'dictionary'), + ('image', image_data, 'image') + ] + + if table_available: + test_data.append(('users', table_data, 'table')) + + try: + # Send the message + print('Sending mixed payloads...') + env, env_json_str = await smartsend( + TEST_SUBJECT, + test_data, + broker_url=TEST_BROKER_URL, + fileserver_url=TEST_FILESERVER_URL, + correlation_id=correlation_id, + msg_purpose='test', + sender_name='py-mix-test', + is_publish=False + ) + + print('\n=== Envelope Created ===') + print(f'Correlation ID: {env["correlation_id"]}') + print(f'Message ID: {env["msg_id"]}') + print(f'Timestamp: {env["timestamp"]}') + print(f'Subject: {env["send_to"]}') + print(f'Purpose: {env["msg_purpose"]}') + print(f'Sender: {env["sender_name"]}') + print(f'Payloads: {len(env["payloads"])}\n') + + # Validate envelope structure + print('=== Validation ===') + passed = True + + expected_count = 4 if table_available else 3 + if len(env['payloads']) != expected_count: + print(f'❌ Expected {expected_count} payloads, got {len(env["payloads"])}') + passed = False + else: + print('✅ Correct number of payloads') + + # Test each payload + expected_datanames = ['message', 'config', 'image'] + expected_types = ['text', 'dictionary', 'image'] + expected_data = [text_data, dict_data, image_data] + + if table_available: + expected_datanames.append('users') + expected_types.append('table') + + for i in range(len(env['payloads'])): + payload = env['payloads'][i] + + if payload['dataname'] != expected_datanames[i]: + print(f"❌ Payload {i + 1}: Expected dataname '{expected_datanames[i]}', got '{payload['dataname']}'") + passed = False + else: + print(f'✅ Payload {i + 1}: Correct dataname') + + if payload['payload_type'] != expected_types[i]: + print(f"❌ Payload {i + 1}: Expected type '{expected_types[i]}', got '{payload['payload_type']}'") + passed = False + else: + print(f'✅ Payload {i + 1}: Correct type') + + if payload['transport'] != 'direct': + print(f"❌ Payload {i + 1}: Expected transport 'direct', got '{payload['transport']}'") + passed = False + else: + print(f'✅ Payload {i + 1}: Correct transport') + + if payload['encoding'] != 'base64': + print(f"❌ Payload {i + 1}: Expected encoding 'base64', got '{payload['encoding']}'") + passed = False + else: + print(f'✅ Payload {i + 1}: Correct encoding') + + # Verify data integrity based on type + decoded_data = base64.b64decode(payload['data']) + + if expected_types[i] == 'text': + decoded_text = decoded_data.decode('utf8') + if decoded_text != expected_data[i]: + print(f'❌ Payload {i + 1}: Data integrity mismatch') + passed = False + else: + print(f'✅ Payload {i + 1}: Data integrity verified') + elif expected_types[i] == 'dictionary': + import json + decoded_dict = json.loads(decoded_data.decode('utf8')) + if json.dumps(decoded_dict, sort_keys=True) != json.dumps(expected_data[i], sort_keys=True): + print(f'❌ Payload {i + 1}: Data integrity mismatch') + passed = False + else: + print(f'✅ Payload {i + 1}: Data integrity verified') + elif expected_types[i] == 'image': + if decoded_data != expected_data[i]: + print(f'❌ Payload {i + 1}: Data integrity mismatch') + passed = False + else: + print(f'✅ Payload {i + 1}: Data integrity verified') + elif expected_types[i] == 'table': + if len(decoded_data) > 0: + print(f'✅ Payload {i + 1}: Arrow IPC data present ({len(decoded_data)} bytes)') + else: + print(f'❌ Payload {i + 1}: Arrow IPC data is empty') + passed = False + + print(f' Size: {payload["size"]} bytes\n') + + # Test with chat-like payload (text + image + audio) + print('=== Chat-like Payload Test ===') + chat_data = [ + ('text', 'Hello!', 'text'), + ('image', bytes([0xFF, 0xD8, 0xFF, 0xE0]), 'image'), + ('audio', bytes([0x46, 0x4C, 0x41, 0x43]), 'audio') + ] + + chat_env, _ = await smartsend( + TEST_SUBJECT, + chat_data, + broker_url=TEST_BROKER_URL, + fileserver_url=TEST_FILESERVER_URL, + correlation_id='chat-' + correlation_id, + is_publish=False + ) + + if len(chat_env['payloads']) == 3: + print('✅ Chat-like payloads handled correctly') + else: + print('❌ Chat-like payloads handling failed') + passed = False + + # Final result + print('\n=== Test Result ===') + if passed: + print('✅ ALL TESTS PASSED') + sys.exit(0) + else: + print('❌ SOME TESTS FAILED') + sys.exit(1) + + except Exception as e: + print(f'❌ Test failed with error: {e}') + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == '__main__': + asyncio.run(run_test()) \ No newline at end of file diff --git a/test/test_py_table_sender.py b/test/test_py_table_sender.py new file mode 100644 index 0000000..1c04eb0 --- /dev/null +++ b/test/test_py_table_sender.py @@ -0,0 +1,167 @@ +""" +Python Table Sender Test +Tests the smartsend function with table (Arrow IPC) payloads +""" + +import asyncio +import sys +import os + +# Add parent directory to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from natbridge import smartsend, DEFAULT_BROKER_URL, DEFAULT_FILESERVER_URL + +TEST_SUBJECT = '/test/table' +TEST_BROKER_URL = os.environ.get('NATS_URL', 'nats://localhost:4222') +TEST_FILESERVER_URL = os.environ.get('FILESERVER_URL', 'http://localhost:8080') + + +async def run_test(): + print('=== Python Table Sender Test ===\n') + + correlation_id = 'py-table-test-' + str(asyncio.get_event_loop().time() * 1000000) + print(f'Correlation ID: {correlation_id}') + print(f'Subject: {TEST_SUBJECT}') + print(f'Broker URL: {TEST_BROKER_URL}\n') + + # Test data - pandas DataFrame + try: + import pandas as pd + table_data = pd.DataFrame({ + 'id': [1, 2, 3, 4, 5], + 'name': ['Alice', 'Bob', 'Charlie', 'David', 'Eve'], + 'age': [30, 25, 35, 28, 32], + 'active': [True, False, True, True, False] + }) + table_available = True + except ImportError: + print('❌ pandas not available - skipping table tests') + sys.exit(0) + + test_data = [ + ('users_table', table_data, 'table') + ] + + try: + # Send the message + print('Sending table payload...') + env, env_json_str = await smartsend( + TEST_SUBJECT, + test_data, + broker_url=TEST_BROKER_URL, + fileserver_url=TEST_FILESERVER_URL, + correlation_id=correlation_id, + msg_purpose='test', + sender_name='py-table-test', + is_publish=False + ) + + print('\n=== Envelope Created ===') + print(f'Correlation ID: {env["correlation_id"]}') + print(f'Message ID: {env["msg_id"]}') + print(f'Timestamp: {env["timestamp"]}') + print(f'Subject: {env["send_to"]}') + print(f'Purpose: {env["msg_purpose"]}') + print(f'Sender: {env["sender_name"]}') + print(f'Payloads: {len(env["payloads"])}\n') + + # Validate envelope structure + print('=== Validation ===') + passed = True + + if len(env['payloads']) != 1: + print(f'❌ Expected 1 payload, got {len(env["payloads"])}') + passed = False + else: + print('✅ Correct number of payloads') + + payload = env['payloads'][0] + if payload['dataname'] != 'users_table': + print(f"❌ Expected dataname 'users_table', got '{payload['dataname']}'") + passed = False + else: + print('✅ Correct dataname') + + if payload['payload_type'] != 'table': + print(f"❌ Expected payload_type 'table', got '{payload['payload_type']}'") + passed = False + else: + print('✅ Correct payload_type') + + if payload['transport'] != 'direct': + print(f"❌ Expected transport 'direct', got '{payload['transport']}'") + passed = False + else: + print('✅ Correct transport') + + if payload['encoding'] != 'base64': + print(f"❌ Expected encoding 'base64', got '{payload['encoding']}'") + passed = False + else: + print('✅ Correct encoding') + + print(f'\nPayload size: {payload["size"]} bytes') + + # Test with larger table + print('\n=== Larger Table Test ===') + large_table_data = pd.DataFrame({ + 'id': range(100), + 'name': [f'User{i}' for i in range(100)], + 'age': [20 + (i % 50) for i in range(100)], + 'active': [i % 2 == 0 for i in range(100)] + }) + + large_test_data = [ + ('large_table', large_table_data, 'table') + ] + + large_env, _ = await smartsend( + TEST_SUBJECT, + large_test_data, + broker_url=TEST_BROKER_URL, + fileserver_url=TEST_FILESERVER_URL, + correlation_id='large-' + correlation_id, + is_publish=False + ) + + if len(large_env['payloads']) == 1: + print('✅ Large table handled correctly') + print(f' Size: {large_env["payloads"][0]["size"]} bytes') + else: + print('❌ Large table handling failed') + passed = False + + # Test JSON string output + print('\n=== JSON String Output Test ===') + import json + try: + parsed = json.loads(env_json_str) + if parsed['correlation_id'] == env['correlation_id'] and \ + len(parsed['payloads']) == len(env['payloads']): + print('✅ JSON string is valid and matches envelope') + else: + print('❌ JSON string does not match envelope') + passed = False + except json.JSONDecodeError as e: + print(f'❌ JSON string is invalid: {e}') + passed = False + + # Final result + print('\n=== Test Result ===') + if passed: + print('✅ ALL TESTS PASSED') + sys.exit(0) + else: + print('❌ SOME TESTS FAILED') + sys.exit(1) + + except Exception as e: + print(f'❌ Test failed with error: {e}') + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == '__main__': + asyncio.run(run_test()) \ No newline at end of file diff --git a/test/test_py_text_receiver.py b/test/test_py_text_receiver.py new file mode 100644 index 0000000..6758d8b --- /dev/null +++ b/test/test_py_text_receiver.py @@ -0,0 +1,205 @@ +""" +Python Text Receiver Test +Tests the smartreceive function with text payloads +""" + +import asyncio +import sys +import os +import json + +# Add parent directory to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from natbridge import smartreceive, DEFAULT_BROKER_URL, DEFAULT_FILESERVER_URL + +TEST_BROKER_URL = os.environ.get('NATS_URL', 'nats://localhost:4222') +TEST_FILESERVER_URL = os.environ.get('FILESERVER_URL', 'http://localhost:8080') + + +async def run_test(): + print('=== Python Text Receiver Test ===\n') + + # Create a mock NATS message with text payload + test_text = 'Hello, NATSBridge! This is a test message.' + import base64 + + test_data = { + 'correlation_id': 'py-receiver-test-' + str(asyncio.get_event_loop().time() * 1000000), + 'msg_id': 'msg-' + str(asyncio.get_event_loop().time() * 1000000), + 'timestamp': asyncio.get_event_loop().time().isoformat(), + 'send_to': '/test/text', + 'msg_purpose': 'test', + 'sender_name': 'py-text-test', + 'sender_id': 'sender-' + str(asyncio.get_event_loop().time() * 1000000), + 'receiver_name': 'py-receiver', + 'receiver_id': 'receiver-' + str(asyncio.get_event_loop().time() * 1000000), + 'reply_to': '', + 'reply_to_msg_id': '', + 'broker_url': TEST_BROKER_URL, + 'metadata': {}, + 'payloads': [ + { + 'id': 'payload-' + str(asyncio.get_event_loop().time() * 1000000), + 'dataname': 'message', + 'payload_type': 'text', + 'transport': 'direct', + 'encoding': 'base64', + 'size': len(test_text.encode('utf8')), + 'data': base64.b64encode(test_text.encode('utf8')).decode('ascii'), + 'metadata': {'payload_bytes': len(test_text.encode('utf8'))} + } + ] + } + + mock_msg = { + 'payload': json.dumps(test_data) + } + + print('Mock Message Created:') + print(f' Correlation ID: {test_data["correlation_id"]}') + print(f' Payloads: {len(test_data["payloads"])}') + print(f' Payload dataname: {test_data["payloads"][0]["dataname"]}') + print(f' Payload type: {test_data["payloads"][0]["payload_type"]}') + print(f' Transport: {test_data["payloads"][0]["transport"]}\n') + + try: + # Receive and process the message + print('Receiving and processing message...') + env = await smartreceive( + mock_msg, + max_retries=3, + base_delay=100, + max_delay=1000 + ) + + print('\n=== Received Envelope ===') + print(f'Correlation ID: {env["correlation_id"]}') + print(f'Message ID: {env["msg_id"]}') + print(f'Timestamp: {env["timestamp"]}') + print(f'Subject: {env["send_to"]}') + print(f'Payloads: {len(env["payloads"])}\n') + + # Validate received data + print('=== Validation ===') + passed = True + + if not env.get('correlation_id'): + print('❌ correlation_id is missing') + passed = False + else: + print('✅ correlation_id present') + + if len(env['payloads']) != 1: + print(f'❌ Expected 1 payload, got {len(env["payloads"])}') + passed = False + else: + print('✅ Correct number of payloads') + + payload = env['payloads'][0] + if payload[0] != 'message': + print(f"❌ Expected dataname 'message', got '{payload[0]}'") + passed = False + else: + print('✅ Correct dataname') + + if payload[2] != 'text': + print(f"❌ Expected type 'text', got '{payload[2]}'") + passed = False + else: + print('✅ Correct type') + + if payload[1] != test_text: + print('❌ Data mismatch') + print(f' Expected: {test_text}') + print(f' Got: {payload[1]}') + passed = False + else: + print('✅ Data correctly deserialized') + + # Test with multiple text payloads + print('\n=== Multiple Text Payloads Test ===') + multi_test_data = { + 'correlation_id': 'multi-receiver-' + str(asyncio.get_event_loop().time() * 1000000), + 'msg_id': 'msg-' + str(asyncio.get_event_loop().time() * 1000000), + 'timestamp': asyncio.get_event_loop().time().isoformat(), + 'send_to': '/test/text', + 'msg_purpose': 'test', + 'sender_name': 'py-text-test', + 'sender_id': 'sender-' + str(asyncio.get_event_loop().time() * 1000000), + 'receiver_name': 'py-receiver', + 'receiver_id': 'receiver-' + str(asyncio.get_event_loop().time() * 1000000), + 'reply_to': '', + 'reply_to_msg_id': '', + 'broker_url': TEST_BROKER_URL, + 'metadata': {}, + 'payloads': [ + { + 'id': 'payload-1', + 'dataname': 'msg1', + 'payload_type': 'text', + 'transport': 'direct', + 'encoding': 'base64', + 'size': 16, + 'data': base64.b64encode(b'First message').decode('ascii'), + 'metadata': {'payload_bytes': 16} + }, + { + 'id': 'payload-2', + 'dataname': 'msg2', + 'payload_type': 'text', + 'transport': 'direct', + 'encoding': 'base64', + 'size': 16, + 'data': base64.b64encode(b'Second message').decode('ascii'), + 'metadata': {'payload_bytes': 16} + }, + { + 'id': 'payload-3', + 'dataname': 'msg3', + 'payload_type': 'text', + 'transport': 'direct', + 'encoding': 'base64', + 'size': 16, + 'data': base64.b64encode(b'Third message').decode('ascii'), + 'metadata': {'payload_bytes': 16} + } + ] + } + + mock_multi_msg = {'payload': json.dumps(multi_test_data)} + multi_env = await smartreceive(mock_multi_msg) + + if len(multi_env['payloads']) == 3: + print('✅ Multiple payloads handled correctly') + + # Verify each payload + expected_messages = ['First message', 'Second message', 'Third message'] + for i in range(3): + if multi_env['payloads'][i][1] == expected_messages[i]: + print(f'✅ Payload {i + 1} correctly deserialized') + else: + print(f'❌ Payload {i + 1} mismatch') + passed = False + else: + print(f"❌ Expected 3 payloads, got {len(multi_env['payloads'])}") + passed = False + + # Final result + print('\n=== Test Result ===') + if passed: + print('✅ ALL TESTS PASSED') + sys.exit(0) + else: + print('❌ SOME TESTS FAILED') + sys.exit(1) + + except Exception as e: + print(f'❌ Test failed with error: {e}') + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == '__main__': + asyncio.run(run_test()) \ No newline at end of file diff --git a/test/test_py_text_sender.py b/test/test_py_text_sender.py new file mode 100644 index 0000000..d1fa716 --- /dev/null +++ b/test/test_py_text_sender.py @@ -0,0 +1,164 @@ +""" +Python Text Sender Test +Tests the smartsend function with text payloads +""" + +import asyncio +import sys +import os + +# Add parent directory to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from natbridge import smartsend, DEFAULT_BROKER_URL, DEFAULT_FILESERVER_URL + +TEST_SUBJECT = '/test/text' +TEST_BROKER_URL = os.environ.get('NATS_URL', 'nats://localhost:4222') +TEST_FILESERVER_URL = os.environ.get('FILESERVER_URL', 'http://localhost:8080') + + +async def run_test(): + print('=== Python Text Sender Test ===\n') + + correlation_id = 'py-text-test-' + str(asyncio.get_event_loop().time() * 1000000) + print(f'Correlation ID: {correlation_id}') + print(f'Subject: {TEST_SUBJECT}') + print(f'Broker URL: {TEST_BROKER_URL}\n') + + # Test data + text_data = 'Hello, NATSBridge! This is a test message.' + test_data = [ + ('message', text_data, 'text') + ] + + try: + # Send the message + print('Sending text payload...') + env, env_json_str = await smartsend( + TEST_SUBJECT, + test_data, + broker_url=TEST_BROKER_URL, + fileserver_url=TEST_FILESERVER_URL, + correlation_id=correlation_id, + msg_purpose='test', + sender_name='py-text-test', + is_publish=False # Don't actually publish for this test + ) + + print('\n=== Envelope Created ===') + print(f'Correlation ID: {env["correlation_id"]}') + print(f'Message ID: {env["msg_id"]}') + print(f'Timestamp: {env["timestamp"]}') + print(f'Subject: {env["send_to"]}') + print(f'Purpose: {env["msg_purpose"]}') + print(f'Sender: {env["sender_name"]}') + print(f'Payloads: {len(env["payloads"])}\n') + + # Validate envelope structure + print('=== Validation ===') + passed = True + + if not env.get('correlation_id'): + print('❌ correlation_id is missing') + passed = False + else: + print('✅ correlation_id present') + + if not env.get('msg_id'): + print('❌ msg_id is missing') + passed = False + else: + print('✅ msg_id present') + + if not env.get('timestamp'): + print('❌ timestamp is missing') + passed = False + else: + print('✅ timestamp present') + + if len(env['payloads']) != 1: + print(f'❌ Expected 1 payload, got {len(env["payloads"])}') + passed = False + else: + print('✅ Correct number of payloads') + + payload = env['payloads'][0] + if payload['dataname'] != 'message': + print(f"❌ Expected dataname 'message', got '{payload['dataname']}'") + passed = False + else: + print('✅ Correct dataname') + + if payload['payload_type'] != 'text': + print(f"❌ Expected payload_type 'text', got '{payload['payload_type']}'") + passed = False + else: + print('✅ Correct payload_type') + + if payload['transport'] != 'direct': + print(f"❌ Expected transport 'direct', got '{payload['transport']}'") + passed = False + else: + print('✅ Correct transport') + + if payload['encoding'] != 'base64': + print(f"❌ Expected encoding 'base64', got '{payload['encoding']}'") + passed = False + else: + print('✅ Correct encoding') + + # Decode and verify the data + import base64 + decoded_data = base64.b64decode(payload['data']).decode('utf8') + if decoded_data != text_data: + print('❌ Decoded data mismatch') + print(f' Expected: {text_data}') + print(f' Got: {decoded_data}') + passed = False + else: + print('✅ Data integrity verified') + + print(f"\nPayload size: {payload['size']} bytes") + print(f'Base64 data length: {len(payload["data"])} chars') + + # Test with multiple text payloads + print('\n=== Multiple Text Payloads Test ===') + multi_test_data = [ + ('msg1', 'First message', 'text'), + ('msg2', 'Second message', 'text'), + ('msg3', 'Third message', 'text') + ] + + multi_env, _ = await smartsend( + TEST_SUBJECT, + multi_test_data, + broker_url=TEST_BROKER_URL, + fileserver_url=TEST_FILESERVER_URL, + correlation_id='multi-test-' + correlation_id, + is_publish=False + ) + + if len(multi_env['payloads']) == 3: + print('✅ Multiple payloads handled correctly') + else: + print(f"❌ Expected 3 payloads, got {len(multi_env['payloads'])}") + passed = False + + # Final result + print('\n=== Test Result ===') + if passed: + print('✅ ALL TESTS PASSED') + sys.exit(0) + else: + print('❌ SOME TESTS FAILED') + sys.exit(1) + + except Exception as e: + print(f'❌ Test failed with error: {e}') + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == '__main__': + asyncio.run(run_test()) \ No newline at end of file