Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion src/dummy-http-server/DummyHttpServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,15 @@ public class DummyHttpServer : IDisposable
private int _port = 29743;
private readonly TimeSpan? _withStartDelay;

/// <summary>
/// Initializes a configurable in-process dummy HTTP server used for testing endpoints.
/// </summary>
/// <param name="withTokenAuth">If true, enable JWT bearer authentication and authorization.</param>
/// <param name="withBasicAuth">If true, enable basic authentication behavior in the test endpoint.</param>
/// <param name="withRetriableError">If true, configure the test endpoint to produce retriable error responses.</param>
/// <param name="withErrorMessage">If true, include error messages in test error responses.</param>
/// <param name="withStartDelay">Optional delay applied when starting the server.</param>
/// <param name="requireClientCert">If true, require client TLS certificates for HTTPS connections.</param>
public DummyHttpServer(bool withTokenAuth = false, bool withBasicAuth = false, bool withRetriableError = false,
bool withErrorMessage = false, TimeSpan? withStartDelay = null, bool requireClientCert = false)
{
Expand Down Expand Up @@ -108,6 +117,13 @@ public void Dispose()
_app.StopAsync().Wait();
}

/// <summary>
/// Clears the in-memory receive buffers and resets the endpoint error state and counter.
/// </summary>
/// <remarks>
/// Empties IlpEndpoint.ReceiveBuffer and IlpEndpoint.ReceiveBytes, sets IlpEndpoint.LastError to null,
/// and sets IlpEndpoint.Counter to zero.
/// </remarks>
public void Clear()
{
IlpEndpoint.ReceiveBuffer.Clear();
Expand All @@ -116,6 +132,12 @@ public void Clear()
IlpEndpoint.Counter = 0;
}

/// <summary>
/// Starts the HTTP server on the specified port and configures the supported protocol versions.
/// </summary>
/// <param name="port">Port to listen on (defaults to 29743).</param>
/// <param name="versions">Array of supported protocol versions; defaults to {1, 2, 3} when null.</param>
/// <returns>A task that completes after any configured startup delay has elapsed and the server's background run task has been initiated.</returns>
public async Task StartAsync(int port = 29743, int[]? versions = null)
{
if (_withStartDelay.HasValue)
Expand All @@ -128,6 +150,9 @@ public async Task StartAsync(int port = 29743, int[]? versions = null)
_ = _app.RunAsync($"http://localhost:{port}");
}

/// <summary>
/// Starts the web application and listens for HTTP requests on http://localhost:{_port}.
/// </summary>
public async Task RunAsync()
{
await _app.RunAsync($"http://localhost:{_port}");
Expand All @@ -138,11 +163,19 @@ public async Task StopAsync()
await _app.StopAsync();
}

/// <summary>
/// Gets the server's in-memory text buffer of received data.
/// </summary>
/// <returns>The mutable <see cref="StringBuilder"/> containing the accumulated received text; modifying it updates the server's buffer.</returns>
public StringBuilder GetReceiveBuffer()
{
return IlpEndpoint.ReceiveBuffer;
}

/// <summary>
/// Gets the in-memory list of bytes received by the ILP endpoint.
/// </summary>
/// <returns>The mutable list of bytes received by the endpoint.</returns>
public List<byte> GetReceivedBytes()
{
return IlpEndpoint.ReceiveBytes;
Expand All @@ -160,6 +193,10 @@ public async Task<bool> Healthcheck()
}


/// <summary>
/// Generates a JWT for the test server when the provided credentials match the server's static username and password.
/// </summary>
/// <returns>The JWT string when credentials are valid; <c>null</c> otherwise. The issued token is valid for one day.</returns>
public string? GetJwtToken(string username, string password)
{
if (username == Username && password == Password)
Expand All @@ -180,6 +217,11 @@ public int GetCounter()
return IlpEndpoint.Counter;
}

/// <summary>
/// Produces a human-readable string representation of the server's received-bytes buffer, interpreting embedded markers and formatting arrays and numeric values.
/// </summary>
/// <returns>The formatted textual representation of the received bytes buffer.</returns>
/// <exception cref="NotImplementedException">Thrown when the buffer contains an unsupported type code.</exception>
public string PrintBuffer()
{
var bytes = GetReceivedBytes().ToArray();
Expand Down Expand Up @@ -263,4 +305,4 @@ public string PrintBuffer()
sb.Append(Encoding.UTF8.GetString(bytes, lastAppend, i - lastAppend));
return sb.ToString();
}
}
}
21 changes: 21 additions & 0 deletions src/net-questdb-client-tests/DecimalTestHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ namespace net_questdb_client_tests;

internal static class DecimalTestHelpers
{
/// <summary>
/// Asserts that the buffer contains a decimal field for the specified column with the given scale and mantissa bytes.
/// </summary>
/// <param name="buffer">The encoded row payload to search for the column's decimal field.</param>
/// <param name="columnName">The name of the column whose decimal payload is expected in the buffer.</param>
/// <param name="expectedScale">The expected scale byte of the decimal field.</param>
/// <param name="expectedMantissa">The expected mantissa bytes of the decimal field.</param>
internal static void AssertDecimalField(ReadOnlySpan<byte> buffer,
string columnName,
byte expectedScale,
Expand All @@ -49,6 +56,14 @@ internal static void AssertDecimalField(ReadOnlySpan<byte> buffer,
$"Mantissa bytes for `{columnName}` did not match expectation.");
}

/// <summary>
/// Asserts that the buffer contains a null decimal field payload for the specified column.
/// </summary>
/// <param name="buffer">Buffer containing the encoded record(s) to inspect.</param>
/// <param name="columnName">Name of the column whose decimal payload should be null.</param>
/// <remarks>
/// Verifies the payload starts with '=' then the DECIMAL type marker, and that both scale and mantissa length are zero.
/// </remarks>
internal static void AssertDecimalNullField(ReadOnlySpan<byte> buffer, string columnName)
{
var payload = ExtractDecimalPayload(buffer, columnName);
Expand All @@ -61,6 +76,12 @@ internal static void AssertDecimalNullField(ReadOnlySpan<byte> buffer, string co
Assert.That(payload[3], Is.EqualTo(0), $"Unexpected mantissa length for `{columnName}`.");
}

/// <summary>
/// Locate and return the payload bytes for a decimal column identified by name.
/// </summary>
/// <param name="buffer">The byte span containing the encoded record payload to search.</param>
/// <param name="columnName">The column name whose payload prefix ("columnName=") will be located.</param>
/// <returns>The slice of <paramref name="buffer"/> immediately after the found "columnName=" prefix.</returns>
private static ReadOnlySpan<byte> ExtractDecimalPayload(ReadOnlySpan<byte> buffer, string columnName)
{
var prefix = Encoding.ASCII.GetBytes($"{columnName}=");
Expand Down
51 changes: 51 additions & 0 deletions src/net-questdb-client-tests/DummyIlpServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ public class DummyIlpServer : IDisposable
private string? _publicKeyY;
private volatile int _totalReceived;

/// <summary>
/// Initializes the dummy ILP server and starts a TCP listener bound to the loopback interface.
/// </summary>
/// <param name="port">TCP port to listen on.</param>
/// <param name="tls">If true, enables TLS for incoming connections.</param>
public DummyIlpServer(int port, bool tls)
{
_tls = tls;
Expand All @@ -69,6 +74,12 @@ public void AcceptAsync()
Task.Run(AcceptConnections);
}

/// <summary>
/// Accepts a single incoming connection, optionally negotiates TLS and performs server authentication, then reads and saves data from the client.
/// </summary>
/// <remarks>
/// Handles one client socket from the listener, wraps the connection with TLS if configured, invokes server-auth when credentials are set, and delegates continuous data receipt to the save routine. Socket errors are caught and the client socket is disposed on exit.
/// </remarks>
private async Task AcceptConnections()
{
Socket? clientSocket = null;
Expand Down Expand Up @@ -107,6 +118,11 @@ private X509Certificate GetCertificate()
return X509Certificate.CreateFromCertFile("certificate.pfx");
}

/// <summary>
/// Performs the server-side authentication handshake over the given stream using a challenge-response ECDSA verification.
/// </summary>
/// <param name="connection">Stream used for the authentication handshake; the method may write to it and will close it if the requested key id mismatches or the signature verification fails.</param>
/// <exception cref="InvalidOperationException">Thrown when the configured public key coordinates are not set.</exception>
private async Task RunServerAuth(Stream connection)
{
var receivedLen = await ReceiveUntilEol(connection);
Expand Down Expand Up @@ -165,6 +181,11 @@ private static string Pad(string text)
return text + new string('=', padding);
}

/// <summary>
/// Decode a Base64 string that may use URL-safe characters and missing padding into its raw byte representation.
/// </summary>
/// <param name="encodedPrivateKey">A Base64-encoded string which may use '-' and '_' instead of '+' and '/' and may omit padding.</param>
/// <returns>The decoded bytes represented by the normalized Base64 input.</returns>
public static byte[] FromBase64String(string encodedPrivateKey)
{
var replace = encodedPrivateKey
Expand All @@ -173,6 +194,11 @@ public static byte[] FromBase64String(string encodedPrivateKey)
return Convert.FromBase64String(Pad(replace));
}

/// <summary>
/// Reads bytes from the provided stream until a newline ('\n') byte is encountered, storing any bytes that follow the newline from the final read into the server's receive buffer.
/// </summary>
/// <param name="connection">The stream to read incoming bytes from.</param>
/// <returns>The index position of the newline byte within the internal read buffer.</returns>
private async Task<int> ReceiveUntilEol(Stream connection)
{
var len = 0;
Expand Down Expand Up @@ -223,16 +249,35 @@ private async Task SaveData(Stream connection, Socket socket)
}
}

/// <summary>
/// Produces a human-readable representation of the data received from the connected client.
/// </summary>
/// <returns>A formatted string containing the contents of the server's received buffer.</returns>
public string GetTextReceived()
{
return PrintBuffer();
}

/// <summary>
/// Gets a copy of all bytes received so far.
/// </summary>
/// <returns>A byte array containing the raw bytes received up to this point.</returns>
public byte[] GetReceivedBytes()
{
return _received.ToArray();
}

/// <summary>
/// Converts the server's accumulated receive buffer into a human-readable string by decoding UTF-8 text and expanding embedded binary markers into readable representations.
/// </summary>
/// <remarks>
/// The method scans the internal receive buffer for the marker sequence "==". After the marker a type byte determines how the following bytes are interpreted:
/// - type 14: formats a multi-dimensional array of doubles as "ARRAY&lt;dim1,dim2,...&gt;[v1,v2,...]".
/// - type 16: formats a single double value.
/// All bytes outside these marked sections are decoded as UTF-8 text and included verbatim.
/// </remarks>
/// <returns>A formatted string containing the decoded UTF-8 text and expanded representations of any detected binary markers.</returns>
/// <exception cref="NotImplementedException">Thrown when an unknown type marker is encountered after the marker sequence.</exception>
public string PrintBuffer()
{
var bytes = _received.ToArray();
Expand Down Expand Up @@ -317,6 +362,12 @@ public string PrintBuffer()
return sb.ToString();
}

/// <summary>
/// Enables server-side authentication by configuring the expected key identifier and the ECDSA public key coordinates.
/// </summary>
/// <param name="keyId">The key identifier expected from the client during authentication.</param>
/// <param name="publicKeyX">Base64-encoded X coordinate of the ECDSA public key (secp256r1).</param>
/// <param name="publicKeyY">Base64-encoded Y coordinate of the ECDSA public key (secp256r1).</param>
public void WithAuth(string keyId, string publicKeyX, string publicKeyY)
{
_keyId = keyId;
Expand Down
18 changes: 18 additions & 0 deletions src/net-questdb-client-tests/JsonSpecTestRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ public class JsonSpecTestRunner
private const int HttpPort = 29473;
private static readonly TestCase[]? TestCases = ReadTestCases();

/// <summary>
/// Populate the provided sender with the test case's table, symbols, and columns, then send the prepared row.
/// </summary>
/// <param name="sender">The ISender to configure and use for sending the test case row.</param>
/// <param name="testCase">The test case containing table name, symbols, and typed columns to write.</param>
/// <returns>A task that completes when the prepared row has been sent.</returns>
private static async Task ExecuteTestCase(ISender sender, TestCase testCase)
{
sender.Table(testCase.Table);
Expand Down Expand Up @@ -87,6 +93,10 @@ private static async Task ExecuteTestCase(ISender sender, TestCase testCase)
await sender.SendAsync();
}

/// <summary>
/// Executes the provided test case by sending its configured table, symbols, and columns to a local TCP listener and asserting the listener's received output against the test case's expected result.
/// </summary>
/// <param name="testCase">The test case to run; provides table, symbols, columns to send and a Result describing the expected validation (Status, Line, AnyLines, or BinaryBase64).</param>
[TestCaseSource(nameof(TestCases))]
public async Task RunTcp(TestCase testCase)
{
Expand Down Expand Up @@ -143,6 +153,10 @@ public async Task RunTcp(TestCase testCase)
}
}

/// <summary>
/// Executes the provided test case by sending data over HTTP to a dummy server using a QuestDB sender and validates the server's response according to the test case result.
/// </summary>
/// <param name="testCase">The test case describing table, symbols, columns, and expected result (status, line(s), or base64 binary) to execute and validate.</param>
[TestCaseSource(nameof(TestCases))]
public async Task RunHttp(TestCase testCase)
{
Expand Down Expand Up @@ -259,6 +273,10 @@ public class TestCase
[JsonPropertyName("columns")] public TestCaseColumn[] Columns { get; set; } = null!;
[JsonPropertyName("result")] public TestCaseResult Result { get; set; } = null!;

/// <summary>
/// Provides the test case name for display and logging.
/// </summary>
/// <returns>The TestName of the test case.</returns>
public override string ToString()
{
return TestName;
Expand Down
16 changes: 8 additions & 8 deletions src/net-questdb-client/Buffers/Buffer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,14 @@ namespace QuestDB.Buffers;
public static class Buffer
{
/// <summary>
/// Creates an IBuffer instance, based on the provided protocol version.
/// Creates a concrete IBuffer implementation configured for the specified protocol version.
/// </summary>
/// <param name="bufferSize"></param>
/// <param name="maxNameLen"></param>
/// <param name="maxBufSize"></param>
/// <param name="version"></param>
/// <returns></returns>
/// <exception cref="NotImplementedException"></exception>
/// <param name="bufferSize">Size in bytes of each buffer segment.</param>
/// <param name="maxNameLen">Maximum allowed length for names stored in the buffer.</param>
/// <param name="maxBufSize">Maximum total buffer capacity.</param>
/// <param name="version">Protocol version that determines which concrete buffer implementation to create.</param>
/// <returns>An <see cref="IBuffer"/> instance corresponding to the specified protocol version.</returns>
/// <exception cref="NotImplementedException">Thrown when an unsupported protocol version is provided.</exception>
public static IBuffer Create(int bufferSize, int maxNameLen, int maxBufSize, ProtocolVersion version)
{
return version switch
Expand All @@ -51,4 +51,4 @@ public static IBuffer Create(int bufferSize, int maxNameLen, int maxBufSize, Pro
_ => throw new NotImplementedException(),
};
}
}
}
Loading