From 51c5b56fc4864ec469234653fe30deb6b079e3a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20Dokumac=C4=B1?= Date: Sat, 1 Aug 2026 22:27:35 +0200 Subject: [PATCH 1/8] refactor: reorganize test files --- scripts/unused_pub_detector.js | 2 +- src/checkpoint_worker_property_test.zig | 206 ------ src/checkpoint_worker_test.zig | 200 ++++++ src/config/loader_property_test.zig | 643 ------------------ src/config/loader_test.zig | 638 +++++++++++++++++ ...ing_property_test.zig => logging_test.zig} | 0 src/message_handler_property_test.zig | 273 -------- src/message_handler_test.zig | 264 +++++++ ...y_test.zig => migration_detector_test.zig} | 0 src/migration_executor_property_test.zig | 397 ----------- src/migration_executor_test.zig | 366 ++++++++++ src/msgpack_utils_property_test.zig | 352 ---------- src/msgpack_utils_test.zig | 347 ++++++++++ ...property_test.zig => server_init_test.zig} | 0 ...test.zig => storage_engine_error_test.zig} | 0 src/storage_engine_property_test.zig | 472 ------------- ....zig => storage_engine_stability_test.zig} | 0 src/storage_engine_test.zig | 464 +++++++++++++ src/test_all.zig | 17 +- src/uwebsockets_wrapper_property_test.zig | 400 ----------- src/uwebsockets_wrapper_test.zig | 392 +++++++++++ tests/e2e/src/harness.ts | 1 - 22 files changed, 2677 insertions(+), 2757 deletions(-) delete mode 100644 src/checkpoint_worker_property_test.zig delete mode 100644 src/config/loader_property_test.zig rename src/{logging_property_test.zig => logging_test.zig} (100%) delete mode 100644 src/message_handler_property_test.zig rename src/{migration_detector_property_test.zig => migration_detector_test.zig} (100%) delete mode 100644 src/migration_executor_property_test.zig delete mode 100644 src/msgpack_utils_property_test.zig rename src/{server_init_property_test.zig => server_init_test.zig} (100%) rename src/{storage_engine_error_property_test.zig => storage_engine_error_test.zig} (100%) delete mode 100644 src/storage_engine_property_test.zig rename src/{storage_engine_stability_property_test.zig => storage_engine_stability_test.zig} (100%) delete mode 100644 src/uwebsockets_wrapper_property_test.zig diff --git a/scripts/unused_pub_detector.js b/scripts/unused_pub_detector.js index ea4ba465..e9764770 100644 --- a/scripts/unused_pub_detector.js +++ b/scripts/unused_pub_detector.js @@ -3,7 +3,7 @@ const fs = require("fs"), path = require("path"); const SRC = path.resolve(__dirname, "..", "src"); const EXTERNAL = new Set(["std","builtin","sqlite","msgpack","httpx"]); -const TEST_RE = /_(?:test|property_test|thread_safety_test|perf_test|leak_test)\.zig$|test_helpers\.zig$|_test_helpers\.zig$|timed_test_runner\.zig$|msgpack_test_helpers\.zig$|checkpoint_test_helpers\.zig$|app_test_helpers\.zig$|store_test_helpers\.zig$|test_all\.zig$/; +const TEST_RE = /test/; function log(m) { process.stderr.write(m + "\n"); } diff --git a/src/checkpoint_worker_property_test.zig b/src/checkpoint_worker_property_test.zig deleted file mode 100644 index ebdc86ed..00000000 --- a/src/checkpoint_worker_property_test.zig +++ /dev/null @@ -1,206 +0,0 @@ -const std = @import("std"); - -const checkpoint_helpers = @import("checkpoint_test_helpers.zig"); -const CheckpointWorker = @import("checkpoint_worker.zig").CheckpointWorker; - -const testing = std.testing; - -// 1. No data loss occurs during checkpoint -// 2. WAL size decreases or stays same after successful checkpoint -// 3. Checkpoint metrics accurately reflect operation -// 4. Failed checkpoints don't corrupt database state -// 5. Concurrent reads can continue during checkpoint - -test "checkpoint: integrity - no data loss occurs during checkpoint" { - const allocator = testing.allocator; - - var ctx: checkpoint_helpers.Context = undefined; - try ctx.init(allocator, .{ - .wal_size_threshold = 1024, // 1KB for testing - .time_threshold_sec = 1, // 1 second for testing - .checkpoint_mode = .passive, - }); - defer ctx.deinit(); - - const manager = &ctx.manager; - - // Property: Checkpoint should not lose data - // We verify this by checking that metrics are consistent before and after - const metrics_before = manager.getMetrics(); - - // Simulate WAL growth - manager.wal_size.store(2048, .release); // Exceed threshold - - // Verify shouldCheckpoint returns true - try testing.expect(manager.shouldCheckpoint()); - - // Perform checkpoint - const result = try manager.performCheckpoint(.passive); - - // Verify checkpoint succeeded - try testing.expect(result.success); - - // Verify metrics were updated - const metrics_after = manager.getMetrics(); - try testing.expect(metrics_after.checkpoint_count == metrics_before.checkpoint_count + 1); - try testing.expect(metrics_after.last_checkpoint_time >= metrics_before.last_checkpoint_time); -} - -test "checkpoint: WAL size management - size decreases or stays same after success" { - const allocator = testing.allocator; - - var ctx: checkpoint_helpers.Context = undefined; - try ctx.init(allocator, .{ - .wal_size_threshold = 1024, - .time_threshold_sec = 300, - .checkpoint_mode = .passive, - }); - defer ctx.deinit(); - - const manager = &ctx.manager; - - // Property: WAL size should decrease or stay same after successful checkpoint - const initial_wal_size: usize = 5000; - manager.wal_size.store(initial_wal_size, .release); - - const result = try manager.performCheckpoint(.truncate); - - if (result.success) { - // WAL size after should be <= WAL size before - try testing.expect(result.wal_size_after <= result.wal_size_before); - try testing.expect(result.wal_size_before == initial_wal_size); - } -} - -test "checkpoint: threshold detection - shouldCheckpoint respects thresholds" { - const allocator = testing.allocator; - - var ctx: checkpoint_helpers.Context = undefined; - try ctx.init(allocator, .{ - .wal_size_threshold = 1000, - .time_threshold_sec = 60, - .checkpoint_mode = .passive, - }); - defer ctx.deinit(); - - const manager = &ctx.manager; - - // Property: shouldCheckpoint returns true when size threshold exceeded - manager.wal_size.store(1500, .release); - try testing.expect(manager.shouldCheckpoint()); - - // Property: shouldCheckpoint returns false when under threshold - manager.wal_size.store(500, .release); - manager.last_checkpoint.store(std.time.timestamp(), .release); - try testing.expect(!manager.shouldCheckpoint()); - - // Property: shouldCheckpoint returns true when time threshold exceeded - manager.last_checkpoint.store(std.time.timestamp() - 120, .release); // 2 minutes ago - try testing.expect(manager.shouldCheckpoint()); -} - -test "checkpoint: failure handling - failed checkpoints increment counter" { - const allocator = testing.allocator; - - var ctx: checkpoint_helpers.Context = undefined; - try ctx.init(allocator, .{ - .wal_size_threshold = 1024, - .time_threshold_sec = 300, - .checkpoint_mode = .passive, - }); - defer ctx.deinit(); - - const manager = &ctx.manager; - - // Property: Failed checkpoints increment failure counter - const initial_failures = manager.failed_checkpoint_count.load(.acquire); - - // Note: In real implementation, we would inject a failure here - // For now, we just verify the counter exists and can be read - try testing.expect(initial_failures == 0); -} - -test "checkpoint: metrics accuracy - metrics reflect operations accurately" { - const allocator = testing.allocator; - - var ctx: checkpoint_helpers.Context = undefined; - try ctx.init(allocator, .{ - .wal_size_threshold = 1024, - .time_threshold_sec = 300, - .checkpoint_mode = .passive, - }); - defer ctx.deinit(); - - const manager = &ctx.manager; - - // Property: Metrics accurately reflect checkpoint operations - const metrics_before = manager.getMetrics(); - - // Perform checkpoint - _ = try manager.performCheckpoint(.passive); - - const metrics_after = manager.getMetrics(); - - // Verify checkpoint count increased - try testing.expect(metrics_after.checkpoint_count == metrics_before.checkpoint_count + 1); - - // Verify timestamp updated - try testing.expect(metrics_after.last_checkpoint_time >= metrics_before.last_checkpoint_time); - - // Duration may be 0 for very fast runs; verify value is valid. - try testing.expect(metrics_after.last_checkpoint_duration_ms >= 0); -} - -test "checkpoint: escalation logic - works correctly when needed" { - const allocator = testing.allocator; - - var ctx: checkpoint_helpers.Context = undefined; - try ctx.init(allocator, .{ - .wal_size_threshold = 1024, - .time_threshold_sec = 300, - .checkpoint_mode = .passive, - }); - defer ctx.deinit(); - - const manager = &ctx.manager; - - // Property: Escalation logic works correctly - // Set up scenario where passive checkpoint doesn't reduce WAL much - manager.wal_size.store(10000, .release); - - const result = try manager.performCheckpointWithEscalation(); - - // Verify checkpoint was attempted (success flag should be set) - try testing.expect(result.success); - // Duration may be 0 in fast runs, but should be >= 0 - try testing.expect(result.duration_ms >= 0); -} - -test "checkpoint: Prometheus formatting - output contains all required metrics" { - const allocator = testing.allocator; - - const metrics = CheckpointWorker.CheckpointMetrics{ - .last_checkpoint_time = 1234567890, - .last_checkpoint_duration_ms = 150, - .wal_size_bytes = 5000000, - .checkpoint_count = 42, - .failed_checkpoint_count = 3, - }; - - const prometheus_output = try metrics.toPrometheus(allocator); - defer allocator.free(prometheus_output); - - // Property: Prometheus output contains all required metrics - try testing.expect(std.mem.indexOf(u8, prometheus_output, "zyncbase_checkpoint_last_time_seconds") != null); - try testing.expect(std.mem.indexOf(u8, prometheus_output, "zyncbase_checkpoint_last_duration_ms") != null); - try testing.expect(std.mem.indexOf(u8, prometheus_output, "zyncbase_wal_size_bytes") != null); - try testing.expect(std.mem.indexOf(u8, prometheus_output, "zyncbase_checkpoint_total") != null); - try testing.expect(std.mem.indexOf(u8, prometheus_output, "zyncbase_checkpoint_failed_total") != null); - - // Property: Prometheus output contains correct values - try testing.expect(std.mem.indexOf(u8, prometheus_output, "1234567890") != null); - try testing.expect(std.mem.indexOf(u8, prometheus_output, "150") != null); - try testing.expect(std.mem.indexOf(u8, prometheus_output, "5000000") != null); - try testing.expect(std.mem.indexOf(u8, prometheus_output, "42") != null); - try testing.expect(std.mem.indexOf(u8, prometheus_output, "3") != null); -} diff --git a/src/checkpoint_worker_test.zig b/src/checkpoint_worker_test.zig index ff035684..519bff50 100644 --- a/src/checkpoint_worker_test.zig +++ b/src/checkpoint_worker_test.zig @@ -312,3 +312,203 @@ test "CheckpointWorker: fast shutdown" { // Should be much faster than 60s try testing.expect(duration < 2000); } + +// 1. No data loss occurs during checkpoint +// 2. WAL size decreases or stays same after successful checkpoint +// 3. Checkpoint metrics accurately reflect operation +// 4. Failed checkpoints don't corrupt database state +// 5. Concurrent reads can continue during checkpoint + +test "checkpoint: integrity - no data loss occurs during checkpoint" { + const allocator = testing.allocator; + + var ctx: checkpoint_helpers.Context = undefined; + try ctx.init(allocator, .{ + .wal_size_threshold = 1024, // 1KB for testing + .time_threshold_sec = 1, // 1 second for testing + .checkpoint_mode = .passive, + }); + defer ctx.deinit(); + + const manager = &ctx.manager; + + // Property: Checkpoint should not lose data + // We verify this by checking that metrics are consistent before and after + const metrics_before = manager.getMetrics(); + + // Simulate WAL growth + manager.wal_size.store(2048, .release); // Exceed threshold + + // Verify shouldCheckpoint returns true + try testing.expect(manager.shouldCheckpoint()); + + // Perform checkpoint + const result = try manager.performCheckpoint(.passive); + + // Verify checkpoint succeeded + try testing.expect(result.success); + + // Verify metrics were updated + const metrics_after = manager.getMetrics(); + try testing.expect(metrics_after.checkpoint_count == metrics_before.checkpoint_count + 1); + try testing.expect(metrics_after.last_checkpoint_time >= metrics_before.last_checkpoint_time); +} + +test "checkpoint: WAL size management - size decreases or stays same after success" { + const allocator = testing.allocator; + + var ctx: checkpoint_helpers.Context = undefined; + try ctx.init(allocator, .{ + .wal_size_threshold = 1024, + .time_threshold_sec = 300, + .checkpoint_mode = .passive, + }); + defer ctx.deinit(); + + const manager = &ctx.manager; + + // Property: WAL size should decrease or stay same after successful checkpoint + const initial_wal_size: usize = 5000; + manager.wal_size.store(initial_wal_size, .release); + + const result = try manager.performCheckpoint(.truncate); + + if (result.success) { + // WAL size after should be <= WAL size before + try testing.expect(result.wal_size_after <= result.wal_size_before); + try testing.expect(result.wal_size_before == initial_wal_size); + } +} + +test "checkpoint: threshold detection - shouldCheckpoint respects thresholds" { + const allocator = testing.allocator; + + var ctx: checkpoint_helpers.Context = undefined; + try ctx.init(allocator, .{ + .wal_size_threshold = 1000, + .time_threshold_sec = 60, + .checkpoint_mode = .passive, + }); + defer ctx.deinit(); + + const manager = &ctx.manager; + + // Property: shouldCheckpoint returns true when size threshold exceeded + manager.wal_size.store(1500, .release); + try testing.expect(manager.shouldCheckpoint()); + + // Property: shouldCheckpoint returns false when under threshold + manager.wal_size.store(500, .release); + manager.last_checkpoint.store(std.time.timestamp(), .release); + try testing.expect(!manager.shouldCheckpoint()); + + // Property: shouldCheckpoint returns true when time threshold exceeded + manager.last_checkpoint.store(std.time.timestamp() - 120, .release); // 2 minutes ago + try testing.expect(manager.shouldCheckpoint()); +} + +test "checkpoint: failure handling - failed checkpoints increment counter" { + const allocator = testing.allocator; + + var ctx: checkpoint_helpers.Context = undefined; + try ctx.init(allocator, .{ + .wal_size_threshold = 1024, + .time_threshold_sec = 300, + .checkpoint_mode = .passive, + }); + defer ctx.deinit(); + + const manager = &ctx.manager; + + // Property: Failed checkpoints increment failure counter + const initial_failures = manager.failed_checkpoint_count.load(.acquire); + + // Note: In real implementation, we would inject a failure here + // For now, we just verify the counter exists and can be read + try testing.expect(initial_failures == 0); +} + +test "checkpoint: metrics accuracy - metrics reflect operations accurately" { + const allocator = testing.allocator; + + var ctx: checkpoint_helpers.Context = undefined; + try ctx.init(allocator, .{ + .wal_size_threshold = 1024, + .time_threshold_sec = 300, + .checkpoint_mode = .passive, + }); + defer ctx.deinit(); + + const manager = &ctx.manager; + + // Property: Metrics accurately reflect checkpoint operations + const metrics_before = manager.getMetrics(); + + // Perform checkpoint + _ = try manager.performCheckpoint(.passive); + + const metrics_after = manager.getMetrics(); + + // Verify checkpoint count increased + try testing.expect(metrics_after.checkpoint_count == metrics_before.checkpoint_count + 1); + + // Verify timestamp updated + try testing.expect(metrics_after.last_checkpoint_time >= metrics_before.last_checkpoint_time); + + // Duration may be 0 for very fast runs; verify value is valid. + try testing.expect(metrics_after.last_checkpoint_duration_ms >= 0); +} + +test "checkpoint: escalation logic - works correctly when needed" { + const allocator = testing.allocator; + + var ctx: checkpoint_helpers.Context = undefined; + try ctx.init(allocator, .{ + .wal_size_threshold = 1024, + .time_threshold_sec = 300, + .checkpoint_mode = .passive, + }); + defer ctx.deinit(); + + const manager = &ctx.manager; + + // Property: Escalation logic works correctly + // Set up scenario where passive checkpoint doesn't reduce WAL much + manager.wal_size.store(10000, .release); + + const result = try manager.performCheckpointWithEscalation(); + + // Verify checkpoint was attempted (success flag should be set) + try testing.expect(result.success); + // Duration may be 0 in fast runs, but should be >= 0 + try testing.expect(result.duration_ms >= 0); +} + +test "checkpoint: Prometheus formatting - output contains all required metrics" { + const allocator = testing.allocator; + + const metrics = CheckpointWorker.CheckpointMetrics{ + .last_checkpoint_time = 1234567890, + .last_checkpoint_duration_ms = 150, + .wal_size_bytes = 5000000, + .checkpoint_count = 42, + .failed_checkpoint_count = 3, + }; + + const prometheus_output = try metrics.toPrometheus(allocator); + defer allocator.free(prometheus_output); + + // Property: Prometheus output contains all required metrics + try testing.expect(std.mem.indexOf(u8, prometheus_output, "zyncbase_checkpoint_last_time_seconds") != null); + try testing.expect(std.mem.indexOf(u8, prometheus_output, "zyncbase_checkpoint_last_duration_ms") != null); + try testing.expect(std.mem.indexOf(u8, prometheus_output, "zyncbase_wal_size_bytes") != null); + try testing.expect(std.mem.indexOf(u8, prometheus_output, "zyncbase_checkpoint_total") != null); + try testing.expect(std.mem.indexOf(u8, prometheus_output, "zyncbase_checkpoint_failed_total") != null); + + // Property: Prometheus output contains correct values + try testing.expect(std.mem.indexOf(u8, prometheus_output, "1234567890") != null); + try testing.expect(std.mem.indexOf(u8, prometheus_output, "150") != null); + try testing.expect(std.mem.indexOf(u8, prometheus_output, "5000000") != null); + try testing.expect(std.mem.indexOf(u8, prometheus_output, "42") != null); + try testing.expect(std.mem.indexOf(u8, prometheus_output, "3") != null); +} diff --git a/src/config/loader_property_test.zig b/src/config/loader_property_test.zig deleted file mode 100644 index cc7efbc9..00000000 --- a/src/config/loader_property_test.zig +++ /dev/null @@ -1,643 +0,0 @@ -const std = @import("std"); - -const c = @cImport({ - @cInclude("stdlib.h"); -}); - -const schema_helpers = @import("../schema/test_helpers.zig"); -const ConfigLoader = @import("loader.zig").ConfigLoader; -const Config = @import("state.zig").Config; - -// Configuration validation properties -// Invariant: Environment variable substitution -// For any configuration field containing ${VAR_NAME} syntax, the environment variable value should be substituted if it exists. -test "config: env var substitution" { - const allocator = std.testing.allocator; - - // Set up test environment variables using C setenv - _ = c.setenv("TEST_PORT", "8080", 1); - _ = c.setenv("TEST_HOST", "192.168.1.1", 1); - _ = c.setenv("TEST_JWT_SECRET", "test-secret-key", 1); - _ = c.setenv("TEST_DATA_DIR", "/tmp/test-isolated-dir", 1); - defer { - _ = c.unsetenv("TEST_PORT"); - _ = c.unsetenv("TEST_HOST"); - _ = c.unsetenv("TEST_JWT_SECRET"); - _ = c.unsetenv("TEST_DATA_DIR"); - } - - var context = try schema_helpers.TestContext.init(allocator, "config-env-vars"); - defer context.deinit(); - - const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-env-vars.json" }); - defer allocator.free(temp_file_path); - const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-env.json" }); - defer allocator.free(schema_file_path); - - // Create config with environment variable substitution - const config_content = try std.fmt.allocPrint(allocator, - \\{{ - \\ "server": {{ - \\ "port": ${{TEST_PORT}}, - \\ "host": "${{TEST_HOST}}" - \\ }}, - \\ "authentication": {{ - \\ "jwt": {{ - \\ "secret": "${{TEST_JWT_SECRET}}" - \\ }} - \\ }}, - \\ "dataDir": "${{TEST_DATA_DIR}}", - \\ "schema": "{s}" - \\}} - , .{schema_file_path}); - defer allocator.free(config_content); - - try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); - try std.fs.cwd().writeFile(.{ .sub_path = schema_file_path, .data = "{}" }); - - var config = try ConfigLoader.load(allocator, temp_file_path); - defer config.deinit(); - - // Verify environment variables were substituted - try std.testing.expectEqual(@as(u16, 8080), config.server.port); - try std.testing.expectEqualStrings("192.168.1.1", config.server.host); - try std.testing.expect(config.authentication.jwt_secret != null); - try std.testing.expectEqualStrings("test-secret-key", config.authentication.jwt_secret.?); - try std.testing.expectEqualStrings("/tmp/test-isolated-dir", config.data_dir); -} - -test "config: env var substitution - missing variable keeps original" { - const allocator = std.testing.allocator; - - // Ensure the variable doesn't exist - _ = c.unsetenv("NONEXISTENT_VAR"); - - var context = try schema_helpers.TestContext.init(allocator, "config-missing-env"); - defer context.deinit(); - - const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-missing-env-var.json" }); - defer allocator.free(temp_file_path); - const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-missing-env.json" }); - defer allocator.free(schema_file_path); - - // Create config with non-existent environment variable - // We use context.test_dir to ensure the directory is cleaned up even if substitution fails - const config_content = try std.fmt.allocPrint(allocator, - \\{{ - \\ "dataDir": "{s}/${{NONEXISTENT_VAR}}", - \\ "schema": "{s}" - \\}} - , .{ context.test_dir, schema_file_path }); - defer allocator.free(config_content); - - try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); - try std.fs.cwd().writeFile(.{ .sub_path = schema_file_path, .data = "{}" }); - - var config = try ConfigLoader.load(allocator, temp_file_path); - defer config.deinit(); - - // Verify original pattern is kept when variable doesn't exist - const expected_data_dir = try std.fmt.allocPrint(allocator, "{s}/${{NONEXISTENT_VAR}}", .{context.test_dir}); - defer allocator.free(expected_data_dir); - try std.testing.expectEqualStrings(expected_data_dir, config.data_dir); -} - -test "config: env var substitution - multiple variables" { - const allocator = std.testing.allocator; - - // Set up multiple test environment variables - _ = c.setenv("TEST_ORIGIN_1", "https://example.com", 1); - _ = c.setenv("TEST_ORIGIN_2", "https://app.example.com", 1); - _ = c.setenv("TEST_RATE_LIMIT", "200", 1); - defer { - _ = c.unsetenv("TEST_ORIGIN_1"); - _ = c.unsetenv("TEST_ORIGIN_2"); - _ = c.unsetenv("TEST_RATE_LIMIT"); - } - - var context = try schema_helpers.TestContext.init(allocator, "config-multiple-env"); - defer context.deinit(); - - const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-multiple-env-vars.json" }); - defer allocator.free(temp_file_path); - const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-multiple-env.json" }); - defer allocator.free(schema_file_path); - - // Create config with multiple environment variables - const config_content = try std.fmt.allocPrint(allocator, - \\{{ - \\ "security": {{ - \\ "allowedOrigins": ["${{TEST_ORIGIN_1}}", "${{TEST_ORIGIN_2}}"], - \\ "maxMessagesPerSecond": ${{TEST_RATE_LIMIT}} - \\ }}, - \\ "schema": "{s}" - \\}} - , .{schema_file_path}); - defer allocator.free(config_content); - - try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); - try std.fs.cwd().writeFile(.{ .sub_path = schema_file_path, .data = "{}" }); - - var config = try ConfigLoader.load(allocator, temp_file_path); - defer config.deinit(); - - // Verify all environment variables were substituted - try std.testing.expectEqual(@as(usize, 2), config.security.allowed_origins.len); - try std.testing.expectEqualStrings("https://example.com", config.security.allowed_origins[0]); - try std.testing.expectEqualStrings("https://app.example.com", config.security.allowed_origins[1]); - try std.testing.expectEqual(@as(u32, 200), config.security.max_messages_per_second); -} - -// Server configuration properties -// Invariant: Configuration validation -// For any configuration, validation should catch invalid values and return descriptive errors. -test "config: validation - invalid port" { - const allocator = std.testing.allocator; - - var context = try schema_helpers.TestContext.init(allocator, "config-invalid-port"); - defer context.deinit(); - - const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-invalid-port.json" }); - defer allocator.free(temp_file_path); - const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-invalid-port.json" }); - defer allocator.free(schema_file_path); - - const config_content = try std.fmt.allocPrint(allocator, - \\{{ - \\ "server": {{ - \\ "port": 70000 - \\ }}, - \\ "schema": "{s}" - \\}} - , .{schema_file_path}); - defer allocator.free(config_content); - - try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); - try std.fs.cwd().writeFile(.{ .sub_path = schema_file_path, .data = "{}" }); - - const result = ConfigLoader.load(allocator, temp_file_path); - try std.testing.expectError(error.InvalidPort, result); -} - -test "config: validation - port zero" { - const allocator = std.testing.allocator; - - var context = try schema_helpers.TestContext.init(allocator, "config-port-zero"); - defer context.deinit(); - - const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-port-zero.json" }); - defer allocator.free(temp_file_path); - const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-port-zero.json" }); - defer allocator.free(schema_file_path); - - const config_content = try std.fmt.allocPrint(allocator, - \\{{ - \\ "server": {{ - \\ "port": 0 - \\ }}, - \\ "schema": "{s}" - \\}} - , .{schema_file_path}); - defer allocator.free(config_content); - - try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); - try std.fs.cwd().writeFile(.{ .sub_path = schema_file_path, .data = "{}" }); - - const result = ConfigLoader.load(allocator, temp_file_path); - try std.testing.expectError(error.InvalidPort, result); -} - -test "config: validation - invalid buffer size" { - const allocator = std.testing.allocator; - - var context = try schema_helpers.TestContext.init(allocator, "config-invalid-buffer"); - defer context.deinit(); - - const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-invalid-buffer.json" }); - defer allocator.free(temp_file_path); - const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-invalid-buffer.json" }); - defer allocator.free(schema_file_path); - - const config_content = try std.fmt.allocPrint(allocator, - \\{{ - \\ "performance": {{ - \\ "messageBufferSize": 0 - \\ }}, - \\ "schema": "{s}" - \\}} - , .{schema_file_path}); - defer allocator.free(config_content); - - try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); - try std.fs.cwd().writeFile(.{ .sub_path = schema_file_path, .data = "{}" }); - - const result = ConfigLoader.load(allocator, temp_file_path); - try std.testing.expectError(error.InvalidBufferSize, result); -} - -test "config: validation - invalid max message size" { - const allocator = std.testing.allocator; - - var context = try schema_helpers.TestContext.init(allocator, "config-invalid-max-msg"); - defer context.deinit(); - - const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-invalid-max-message-size.json" }); - defer allocator.free(temp_file_path); - const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-invalid-max-message.json" }); - defer allocator.free(schema_file_path); - - const config_content = try std.fmt.allocPrint(allocator, - \\{{ - \\ "security": {{ - \\ "maxMessageSize": 0 - \\ }}, - \\ "schema": "{s}" - \\}} - , .{schema_file_path}); - defer allocator.free(config_content); - - try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); - try std.fs.cwd().writeFile(.{ .sub_path = schema_file_path, .data = "{}" }); - - const result = ConfigLoader.load(allocator, temp_file_path); - try std.testing.expectError(error.InvalidMaxMessageSize, result); -} - -// Logging configuration properties -// Invariant: Missing schema file falls back to the implicit users-only schema. -test "config: missing schema file is allowed" { - const allocator = std.testing.allocator; - - var context = try schema_helpers.TestContext.init(allocator, "config-missing-schema"); - defer context.deinit(); - - const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-missing-schema.json" }); - defer allocator.free(temp_file_path); - - const config_content = - \\{ - \\ "schema": "/nonexistent/schema.json" - \\} - ; - - try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); - var config = try ConfigLoader.load(allocator, temp_file_path); - defer config.deinit(); - try std.testing.expectEqualStrings("/nonexistent/schema.json", config.schema_file); - try std.testing.expect(config.schema_content == null); -} - -test "config: file existence validation - auth rules file not found" { - const allocator = std.testing.allocator; - - var context = try schema_helpers.TestContext.init(allocator, "config-missing-auth"); - defer context.deinit(); - - const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-missing-auth-rules.json" }); - defer allocator.free(temp_file_path); - const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-missing-auth.json" }); - defer allocator.free(schema_file_path); - - const config_content = try std.fmt.allocPrint(allocator, - \\{{ - \\ "authorization": "/nonexistent/auth-rules.json", - \\ "schema": "{s}" - \\}} - , .{schema_file_path}); - defer allocator.free(config_content); - - try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); - try std.fs.cwd().writeFile(.{ .sub_path = schema_file_path, .data = "{}" }); - - const result = ConfigLoader.load(allocator, temp_file_path); - try std.testing.expectError(error.AuthRulesFileNotFound, result); -} - -test "config: file existence validation - valid schema file" { - const allocator = std.testing.allocator; - - var context = try schema_helpers.TestContext.init(allocator, "config-valid-schema"); - defer context.deinit(); - - const schema_file = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-valid-schema-extra.json" }); - defer allocator.free(schema_file); - const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-valid-schema.json" }); - defer allocator.free(temp_file_path); - - try std.fs.cwd().writeFile(.{ .sub_path = schema_file, .data = "{}" }); - - const config_content = try std.fmt.allocPrint(allocator, - \\{{ - \\ "schema": "{s}" - \\}} - , .{schema_file}); - defer allocator.free(config_content); - - try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); - - var config = try ConfigLoader.load(allocator, temp_file_path); - defer config.deinit(); - - // Verify schema file was loaded - try std.testing.expectEqualStrings(schema_file, config.schema_file); -} - -test "config: file existence validation - valid auth rules file" { - const allocator = std.testing.allocator; - - var context = try schema_helpers.TestContext.init(allocator, "config-valid-auth"); - defer context.deinit(); - - const auth_rules_file = try std.fs.path.join(allocator, &.{ context.test_dir, "test-auth-rules.json" }); - defer allocator.free(auth_rules_file); - const schema_file = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-valid-auth.json" }); - defer allocator.free(schema_file); - const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-valid-auth-rules.json" }); - defer allocator.free(temp_file_path); - - try std.fs.cwd().writeFile(.{ .sub_path = auth_rules_file, .data = "{}" }); - try std.fs.cwd().writeFile(.{ .sub_path = schema_file, .data = "{}" }); - - const config_content = try std.fmt.allocPrint(allocator, - \\{{ - \\ "authorization": "{s}", - \\ "schema": "{s}" - \\}} - , .{ auth_rules_file, schema_file }); - defer allocator.free(config_content); - - try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); - - var config = try ConfigLoader.load(allocator, temp_file_path); - defer config.deinit(); - - // Verify auth rules file was loaded - try std.testing.expect(config.authorization_file != null); - try std.testing.expectEqualStrings(auth_rules_file, config.authorization_file.?); -} - -// Authorization selection properties -// Invariant: Configuration round-trip -// For any valid configuration, serializing then parsing should produce an equivalent configuration. -test "config: round-trip - server config" { - const allocator = std.testing.allocator; - - var context = try schema_helpers.TestContext.init(allocator, "config-roundtrip-server"); - defer context.deinit(); - - const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-roundtrip-server.json" }); - defer allocator.free(temp_file_path); - const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-roundtrip-server.json" }); - defer allocator.free(schema_file_path); - - const config_content = try std.fmt.allocPrint(allocator, - \\{{ - \\ "server": {{ - \\ "port": 8080, - \\ "host": "127.0.0.1" - \\ }}, - \\ "schema": "{s}" - \\}} - , .{schema_file_path}); - defer allocator.free(config_content); - - try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); - try std.fs.cwd().writeFile(.{ .sub_path = schema_file_path, .data = "{}" }); - - var config = try ConfigLoader.load(allocator, temp_file_path); - defer config.deinit(); - - // Verify values match original - try std.testing.expectEqual(@as(u16, 8080), config.server.port); - try std.testing.expectEqualStrings("127.0.0.1", config.server.host); -} - -test "config: round-trip - auth config" { - const allocator = std.testing.allocator; - - var context = try schema_helpers.TestContext.init(allocator, "config-roundtrip-auth"); - defer context.deinit(); - - const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-roundtrip-auth.json" }); - defer allocator.free(temp_file_path); - const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-roundtrip-auth.json" }); - defer allocator.free(schema_file_path); - - const config_content = try std.fmt.allocPrint(allocator, - \\{{ - \\ "authentication": {{ - \\ "jwt": {{ - \\ "secret": "my-secret-key", - \\ "algorithm": "HS512", - \\ "issuer": "zyncbase", - \\ "audience": "api" - \\ }} - \\ }}, - \\ "schema": "{s}" - \\}} - , .{schema_file_path}); - defer allocator.free(config_content); - - try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); - try std.fs.cwd().writeFile(.{ .sub_path = schema_file_path, .data = "{}" }); - - var config = try ConfigLoader.load(allocator, temp_file_path); - defer config.deinit(); - - // Verify values match original - try std.testing.expect(config.authentication.jwt_secret != null); - try std.testing.expectEqualStrings("my-secret-key", config.authentication.jwt_secret.?); - try std.testing.expectEqualStrings("HS512", config.authentication.jwt_algorithm); - try std.testing.expect(config.authentication.jwt_issuer != null); - try std.testing.expectEqualStrings("zyncbase", config.authentication.jwt_issuer.?); - try std.testing.expect(config.authentication.jwt_audience != null); - try std.testing.expectEqualStrings("api", config.authentication.jwt_audience.?); -} - -test "config: round-trip - security config" { - const allocator = std.testing.allocator; - - var context = try schema_helpers.TestContext.init(allocator, "config-roundtrip-security"); - defer context.deinit(); - - const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-roundtrip-security.json" }); - defer allocator.free(temp_file_path); - const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-roundtrip-security.json" }); - defer allocator.free(schema_file_path); - - const config_content = try std.fmt.allocPrint(allocator, - \\{{ - \\ "security": {{ - \\ "allowedOrigins": ["https://example.com", "https://app.example.com"], - \\ "allowLocalhost": false, - \\ "maxMessagesPerSecond": 200, - \\ "maxConnections": 20, - \\ "maxMessageSize": 2097152 - \\ }}, - \\ "schema": "{s}" - \\}} - , .{schema_file_path}); - defer allocator.free(config_content); - - try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); - try std.fs.cwd().writeFile(.{ .sub_path = schema_file_path, .data = "{}" }); - - var config = try ConfigLoader.load(allocator, temp_file_path); - defer config.deinit(); - - // Verify values match original - try std.testing.expectEqual(@as(usize, 2), config.security.allowed_origins.len); - try std.testing.expectEqualStrings("https://example.com", config.security.allowed_origins[0]); - try std.testing.expectEqualStrings("https://app.example.com", config.security.allowed_origins[1]); - try std.testing.expectEqual(false, config.security.allow_localhost); - try std.testing.expectEqual(@as(u32, 200), config.security.max_messages_per_second); - try std.testing.expectEqual(@as(u32, 20), config.security.max_connections); - try std.testing.expectEqual(@as(usize, 2097152), config.security.max_message_size); -} - -test "config: round-trip - logging config" { - const allocator = std.testing.allocator; - - var context = try schema_helpers.TestContext.init(allocator, "config-roundtrip-logging"); - defer context.deinit(); - - const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-roundtrip-logging.json" }); - defer allocator.free(temp_file_path); - const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-roundtrip-logging.json" }); - defer allocator.free(schema_file_path); - - const config_content = try std.fmt.allocPrint(allocator, - \\{{ - \\ "logging": {{ - \\ "level": "debug", - \\ "format": "text" - \\ }}, - \\ "schema": "{s}" - \\}} - , .{schema_file_path}); - defer allocator.free(config_content); - - try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); - try std.fs.cwd().writeFile(.{ .sub_path = schema_file_path, .data = "{}" }); - - var config = try ConfigLoader.load(allocator, temp_file_path); - defer config.deinit(); - - // Verify values match original - try std.testing.expectEqual(Config.LoggingConfig.LogLevel.debug, config.logging.level); - try std.testing.expectEqual(Config.LoggingConfig.LogFormat.text, config.logging.format); -} - -test "config: round-trip - performance config" { - const allocator = std.testing.allocator; - - var context = try schema_helpers.TestContext.init(allocator, "config-roundtrip-perf"); - defer context.deinit(); - - const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-roundtrip-performance.json" }); - defer allocator.free(temp_file_path); - const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-roundtrip-performance.json" }); - defer allocator.free(schema_file_path); - - const config_content = try std.fmt.allocPrint(allocator, - \\{{ - \\ "performance": {{ - \\ "messageBufferSize": 2000, - \\ "batchWrites": false, - \\ "batchSize": 50, - \\ "batchTimeout": 20 - \\ }}, - \\ "schema": "{s}" - \\}} - , .{schema_file_path}); - defer allocator.free(config_content); - - try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); - try std.fs.cwd().writeFile(.{ .sub_path = schema_file_path, .data = "{}" }); - - var config = try ConfigLoader.load(allocator, temp_file_path); - defer config.deinit(); - - // Verify values match original - try std.testing.expectEqual(@as(usize, 2000), config.performance.message_buffer_size); - try std.testing.expectEqual(false, config.performance.batch_writes); - try std.testing.expectEqual(@as(usize, 50), config.performance.batch_size); - try std.testing.expectEqual(@as(u32, 20), config.performance.batch_timeout); -} - -test "config: round-trip - complete config" { - const allocator = std.testing.allocator; - - var context = try schema_helpers.TestContext.init(allocator, "config-roundtrip-complete"); - defer context.deinit(); - - const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-roundtrip-complete.json" }); - defer allocator.free(temp_file_path); - const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-roundtrip-complete.json" }); - defer allocator.free(schema_file_path); - - const config_content = try std.fmt.allocPrint(allocator, - \\{{ - \\ "server": {{ - \\ "port": 8080, - \\ "host": "127.0.0.1" - \\ }}, - \\ "authentication": {{ - \\ "jwt": {{ - \\ "secret": "my-secret-key", - \\ "algorithm": "HS512", - \\ "issuer": "zyncbase", - \\ "audience": "api" - \\ }} - \\ }}, - \\ "security": {{ - \\ "allowedOrigins": ["https://example.com"], - \\ "allowLocalhost": false, - \\ "maxMessagesPerSecond": 200, - \\ "maxConnections": 20, - \\ "maxMessageSize": 2097152 - \\ }}, - \\ "logging": {{ - \\ "level": "debug", - \\ "format": "text" - \\ }}, - \\ "performance": {{ - \\ "messageBufferSize": 2000, - \\ "batchWrites": false, - \\ "batchSize": 50, - \\ "batchTimeout": 20 - \\ }}, - \\ "dataDir": "{s}", - \\ "schema": "{s}" - \\}} - , .{ context.test_dir, schema_file_path }); - defer allocator.free(config_content); - - try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); - try std.fs.cwd().writeFile(.{ .sub_path = schema_file_path, .data = "{}" }); - - var config = try ConfigLoader.load(allocator, temp_file_path); - defer config.deinit(); - - // Verify all values match original - try std.testing.expectEqual(@as(u16, 8080), config.server.port); - try std.testing.expectEqualStrings("127.0.0.1", config.server.host); - - try std.testing.expect(config.authentication.jwt_secret != null); - try std.testing.expectEqualStrings("my-secret-key", config.authentication.jwt_secret.?); - try std.testing.expectEqualStrings("HS512", config.authentication.jwt_algorithm); - - try std.testing.expectEqual(@as(usize, 1), config.security.allowed_origins.len); - try std.testing.expectEqualStrings("https://example.com", config.security.allowed_origins[0]); - try std.testing.expectEqual(false, config.security.allow_localhost); - - try std.testing.expectEqual(Config.LoggingConfig.LogLevel.debug, config.logging.level); - try std.testing.expectEqual(Config.LoggingConfig.LogFormat.text, config.logging.format); - - try std.testing.expectEqual(@as(usize, 2000), config.performance.message_buffer_size); - try std.testing.expectEqual(false, config.performance.batch_writes); - try std.testing.expectEqual(@as(usize, 50), config.performance.batch_size); - try std.testing.expectEqual(@as(u32, 20), config.performance.batch_timeout); - - try std.testing.expectEqualStrings(context.test_dir, config.data_dir); -} diff --git a/src/config/loader_test.zig b/src/config/loader_test.zig index bea853a0..8baf2144 100644 --- a/src/config/loader_test.zig +++ b/src/config/loader_test.zig @@ -1,5 +1,9 @@ const std = @import("std"); +const c = @cImport({ + @cInclude("stdlib.h"); +}); + const schema_helpers = @import("../schema/test_helpers.zig"); const ConfigLoader = @import("loader.zig").ConfigLoader; const Config = @import("state.zig").Config; @@ -232,3 +236,637 @@ test "ConfigLoader parses inline schema configuration" { const f1_name = fields.items[1].object.get("name") orelse unreachable; try std.testing.expectEqualStrings("name", f1_name.string); } + +// Configuration validation properties +// Invariant: Environment variable substitution +// For any configuration field containing ${VAR_NAME} syntax, the environment variable value should be substituted if it exists. +test "config: env var substitution" { + const allocator = std.testing.allocator; + + // Set up test environment variables using C setenv + _ = c.setenv("TEST_PORT", "8080", 1); + _ = c.setenv("TEST_HOST", "192.168.1.1", 1); + _ = c.setenv("TEST_JWT_SECRET", "test-secret-key", 1); + _ = c.setenv("TEST_DATA_DIR", "/tmp/test-isolated-dir", 1); + defer { + _ = c.unsetenv("TEST_PORT"); + _ = c.unsetenv("TEST_HOST"); + _ = c.unsetenv("TEST_JWT_SECRET"); + _ = c.unsetenv("TEST_DATA_DIR"); + } + + var context = try schema_helpers.TestContext.init(allocator, "config-env-vars"); + defer context.deinit(); + + const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-env-vars.json" }); + defer allocator.free(temp_file_path); + const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-env.json" }); + defer allocator.free(schema_file_path); + + // Create config with environment variable substitution + const config_content = try std.fmt.allocPrint(allocator, + \\{{ + \\ "server": {{ + \\ "port": ${{TEST_PORT}}, + \\ "host": "${{TEST_HOST}}" + \\ }}, + \\ "authentication": {{ + \\ "jwt": {{ + \\ "secret": "${{TEST_JWT_SECRET}}" + \\ }} + \\ }}, + \\ "dataDir": "${{TEST_DATA_DIR}}", + \\ "schema": "{s}" + \\}} + , .{schema_file_path}); + defer allocator.free(config_content); + + try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); + try std.fs.cwd().writeFile(.{ .sub_path = schema_file_path, .data = "{}" }); + + var config = try ConfigLoader.load(allocator, temp_file_path); + defer config.deinit(); + + // Verify environment variables were substituted + try std.testing.expectEqual(@as(u16, 8080), config.server.port); + try std.testing.expectEqualStrings("192.168.1.1", config.server.host); + try std.testing.expect(config.authentication.jwt_secret != null); + try std.testing.expectEqualStrings("test-secret-key", config.authentication.jwt_secret.?); + try std.testing.expectEqualStrings("/tmp/test-isolated-dir", config.data_dir); +} + +test "config: env var substitution - missing variable keeps original" { + const allocator = std.testing.allocator; + + // Ensure the variable doesn't exist + _ = c.unsetenv("NONEXISTENT_VAR"); + + var context = try schema_helpers.TestContext.init(allocator, "config-missing-env"); + defer context.deinit(); + + const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-missing-env-var.json" }); + defer allocator.free(temp_file_path); + const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-missing-env.json" }); + defer allocator.free(schema_file_path); + + // Create config with non-existent environment variable + // We use context.test_dir to ensure the directory is cleaned up even if substitution fails + const config_content = try std.fmt.allocPrint(allocator, + \\{{ + \\ "dataDir": "{s}/${{NONEXISTENT_VAR}}", + \\ "schema": "{s}" + \\}} + , .{ context.test_dir, schema_file_path }); + defer allocator.free(config_content); + + try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); + try std.fs.cwd().writeFile(.{ .sub_path = schema_file_path, .data = "{}" }); + + var config = try ConfigLoader.load(allocator, temp_file_path); + defer config.deinit(); + + // Verify original pattern is kept when variable doesn't exist + const expected_data_dir = try std.fmt.allocPrint(allocator, "{s}/${{NONEXISTENT_VAR}}", .{context.test_dir}); + defer allocator.free(expected_data_dir); + try std.testing.expectEqualStrings(expected_data_dir, config.data_dir); +} + +test "config: env var substitution - multiple variables" { + const allocator = std.testing.allocator; + + // Set up multiple test environment variables + _ = c.setenv("TEST_ORIGIN_1", "https://example.com", 1); + _ = c.setenv("TEST_ORIGIN_2", "https://app.example.com", 1); + _ = c.setenv("TEST_RATE_LIMIT", "200", 1); + defer { + _ = c.unsetenv("TEST_ORIGIN_1"); + _ = c.unsetenv("TEST_ORIGIN_2"); + _ = c.unsetenv("TEST_RATE_LIMIT"); + } + + var context = try schema_helpers.TestContext.init(allocator, "config-multiple-env"); + defer context.deinit(); + + const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-multiple-env-vars.json" }); + defer allocator.free(temp_file_path); + const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-multiple-env.json" }); + defer allocator.free(schema_file_path); + + // Create config with multiple environment variables + const config_content = try std.fmt.allocPrint(allocator, + \\{{ + \\ "security": {{ + \\ "allowedOrigins": ["${{TEST_ORIGIN_1}}", "${{TEST_ORIGIN_2}}"], + \\ "maxMessagesPerSecond": ${{TEST_RATE_LIMIT}} + \\ }}, + \\ "schema": "{s}" + \\}} + , .{schema_file_path}); + defer allocator.free(config_content); + + try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); + try std.fs.cwd().writeFile(.{ .sub_path = schema_file_path, .data = "{}" }); + + var config = try ConfigLoader.load(allocator, temp_file_path); + defer config.deinit(); + + // Verify all environment variables were substituted + try std.testing.expectEqual(@as(usize, 2), config.security.allowed_origins.len); + try std.testing.expectEqualStrings("https://example.com", config.security.allowed_origins[0]); + try std.testing.expectEqualStrings("https://app.example.com", config.security.allowed_origins[1]); + try std.testing.expectEqual(@as(u32, 200), config.security.max_messages_per_second); +} + +// Server configuration properties +// Invariant: Configuration validation +// For any configuration, validation should catch invalid values and return descriptive errors. +test "config: validation - invalid port" { + const allocator = std.testing.allocator; + + var context = try schema_helpers.TestContext.init(allocator, "config-invalid-port"); + defer context.deinit(); + + const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-invalid-port.json" }); + defer allocator.free(temp_file_path); + const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-invalid-port.json" }); + defer allocator.free(schema_file_path); + + const config_content = try std.fmt.allocPrint(allocator, + \\{{ + \\ "server": {{ + \\ "port": 70000 + \\ }}, + \\ "schema": "{s}" + \\}} + , .{schema_file_path}); + defer allocator.free(config_content); + + try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); + try std.fs.cwd().writeFile(.{ .sub_path = schema_file_path, .data = "{}" }); + + const result = ConfigLoader.load(allocator, temp_file_path); + try std.testing.expectError(error.InvalidPort, result); +} + +test "config: validation - port zero" { + const allocator = std.testing.allocator; + + var context = try schema_helpers.TestContext.init(allocator, "config-port-zero"); + defer context.deinit(); + + const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-port-zero.json" }); + defer allocator.free(temp_file_path); + const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-port-zero.json" }); + defer allocator.free(schema_file_path); + + const config_content = try std.fmt.allocPrint(allocator, + \\{{ + \\ "server": {{ + \\ "port": 0 + \\ }}, + \\ "schema": "{s}" + \\}} + , .{schema_file_path}); + defer allocator.free(config_content); + + try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); + try std.fs.cwd().writeFile(.{ .sub_path = schema_file_path, .data = "{}" }); + + const result = ConfigLoader.load(allocator, temp_file_path); + try std.testing.expectError(error.InvalidPort, result); +} + +test "config: validation - invalid buffer size" { + const allocator = std.testing.allocator; + + var context = try schema_helpers.TestContext.init(allocator, "config-invalid-buffer"); + defer context.deinit(); + + const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-invalid-buffer.json" }); + defer allocator.free(temp_file_path); + const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-invalid-buffer.json" }); + defer allocator.free(schema_file_path); + + const config_content = try std.fmt.allocPrint(allocator, + \\{{ + \\ "performance": {{ + \\ "messageBufferSize": 0 + \\ }}, + \\ "schema": "{s}" + \\}} + , .{schema_file_path}); + defer allocator.free(config_content); + + try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); + try std.fs.cwd().writeFile(.{ .sub_path = schema_file_path, .data = "{}" }); + + const result = ConfigLoader.load(allocator, temp_file_path); + try std.testing.expectError(error.InvalidBufferSize, result); +} + +test "config: validation - invalid max message size" { + const allocator = std.testing.allocator; + + var context = try schema_helpers.TestContext.init(allocator, "config-invalid-max-msg"); + defer context.deinit(); + + const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-invalid-max-message-size.json" }); + defer allocator.free(temp_file_path); + const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-invalid-max-message.json" }); + defer allocator.free(schema_file_path); + + const config_content = try std.fmt.allocPrint(allocator, + \\{{ + \\ "security": {{ + \\ "maxMessageSize": 0 + \\ }}, + \\ "schema": "{s}" + \\}} + , .{schema_file_path}); + defer allocator.free(config_content); + + try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); + try std.fs.cwd().writeFile(.{ .sub_path = schema_file_path, .data = "{}" }); + + const result = ConfigLoader.load(allocator, temp_file_path); + try std.testing.expectError(error.InvalidMaxMessageSize, result); +} + +// Logging configuration properties +// Invariant: Missing schema file falls back to the implicit users-only schema. +test "config: missing schema file is allowed" { + const allocator = std.testing.allocator; + + var context = try schema_helpers.TestContext.init(allocator, "config-missing-schema"); + defer context.deinit(); + + const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-missing-schema.json" }); + defer allocator.free(temp_file_path); + + const config_content = + \\{ + \\ "schema": "/nonexistent/schema.json" + \\} + ; + + try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); + var config = try ConfigLoader.load(allocator, temp_file_path); + defer config.deinit(); + try std.testing.expectEqualStrings("/nonexistent/schema.json", config.schema_file); + try std.testing.expect(config.schema_content == null); +} + +test "config: file existence validation - auth rules file not found" { + const allocator = std.testing.allocator; + + var context = try schema_helpers.TestContext.init(allocator, "config-missing-auth"); + defer context.deinit(); + + const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-missing-auth-rules.json" }); + defer allocator.free(temp_file_path); + const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-missing-auth.json" }); + defer allocator.free(schema_file_path); + + const config_content = try std.fmt.allocPrint(allocator, + \\{{ + \\ "authorization": "/nonexistent/auth-rules.json", + \\ "schema": "{s}" + \\}} + , .{schema_file_path}); + defer allocator.free(config_content); + + try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); + try std.fs.cwd().writeFile(.{ .sub_path = schema_file_path, .data = "{}" }); + + const result = ConfigLoader.load(allocator, temp_file_path); + try std.testing.expectError(error.AuthRulesFileNotFound, result); +} + +test "config: file existence validation - valid schema file" { + const allocator = std.testing.allocator; + + var context = try schema_helpers.TestContext.init(allocator, "config-valid-schema"); + defer context.deinit(); + + const schema_file = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-valid-schema-extra.json" }); + defer allocator.free(schema_file); + const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-valid-schema.json" }); + defer allocator.free(temp_file_path); + + try std.fs.cwd().writeFile(.{ .sub_path = schema_file, .data = "{}" }); + + const config_content = try std.fmt.allocPrint(allocator, + \\{{ + \\ "schema": "{s}" + \\}} + , .{schema_file}); + defer allocator.free(config_content); + + try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); + + var config = try ConfigLoader.load(allocator, temp_file_path); + defer config.deinit(); + + // Verify schema file was loaded + try std.testing.expectEqualStrings(schema_file, config.schema_file); +} + +test "config: file existence validation - valid auth rules file" { + const allocator = std.testing.allocator; + + var context = try schema_helpers.TestContext.init(allocator, "config-valid-auth"); + defer context.deinit(); + + const auth_rules_file = try std.fs.path.join(allocator, &.{ context.test_dir, "test-auth-rules.json" }); + defer allocator.free(auth_rules_file); + const schema_file = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-valid-auth.json" }); + defer allocator.free(schema_file); + const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-valid-auth-rules.json" }); + defer allocator.free(temp_file_path); + + try std.fs.cwd().writeFile(.{ .sub_path = auth_rules_file, .data = "{}" }); + try std.fs.cwd().writeFile(.{ .sub_path = schema_file, .data = "{}" }); + + const config_content = try std.fmt.allocPrint(allocator, + \\{{ + \\ "authorization": "{s}", + \\ "schema": "{s}" + \\}} + , .{ auth_rules_file, schema_file }); + defer allocator.free(config_content); + + try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); + + var config = try ConfigLoader.load(allocator, temp_file_path); + defer config.deinit(); + + // Verify auth rules file was loaded + try std.testing.expect(config.authorization_file != null); + try std.testing.expectEqualStrings(auth_rules_file, config.authorization_file.?); +} + +// Authorization selection properties +// Invariant: Configuration round-trip +// For any valid configuration, serializing then parsing should produce an equivalent configuration. +test "config: round-trip - server config" { + const allocator = std.testing.allocator; + + var context = try schema_helpers.TestContext.init(allocator, "config-roundtrip-server"); + defer context.deinit(); + + const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-roundtrip-server.json" }); + defer allocator.free(temp_file_path); + const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-roundtrip-server.json" }); + defer allocator.free(schema_file_path); + + const config_content = try std.fmt.allocPrint(allocator, + \\{{ + \\ "server": {{ + \\ "port": 8080, + \\ "host": "127.0.0.1" + \\ }}, + \\ "schema": "{s}" + \\}} + , .{schema_file_path}); + defer allocator.free(config_content); + + try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); + try std.fs.cwd().writeFile(.{ .sub_path = schema_file_path, .data = "{}" }); + + var config = try ConfigLoader.load(allocator, temp_file_path); + defer config.deinit(); + + // Verify values match original + try std.testing.expectEqual(@as(u16, 8080), config.server.port); + try std.testing.expectEqualStrings("127.0.0.1", config.server.host); +} + +test "config: round-trip - auth config" { + const allocator = std.testing.allocator; + + var context = try schema_helpers.TestContext.init(allocator, "config-roundtrip-auth"); + defer context.deinit(); + + const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-roundtrip-auth.json" }); + defer allocator.free(temp_file_path); + const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-roundtrip-auth.json" }); + defer allocator.free(schema_file_path); + + const config_content = try std.fmt.allocPrint(allocator, + \\{{ + \\ "authentication": {{ + \\ "jwt": {{ + \\ "secret": "my-secret-key", + \\ "algorithm": "HS512", + \\ "issuer": "zyncbase", + \\ "audience": "api" + \\ }} + \\ }}, + \\ "schema": "{s}" + \\}} + , .{schema_file_path}); + defer allocator.free(config_content); + + try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); + try std.fs.cwd().writeFile(.{ .sub_path = schema_file_path, .data = "{}" }); + + var config = try ConfigLoader.load(allocator, temp_file_path); + defer config.deinit(); + + // Verify values match original + try std.testing.expect(config.authentication.jwt_secret != null); + try std.testing.expectEqualStrings("my-secret-key", config.authentication.jwt_secret.?); + try std.testing.expectEqualStrings("HS512", config.authentication.jwt_algorithm); + try std.testing.expect(config.authentication.jwt_issuer != null); + try std.testing.expectEqualStrings("zyncbase", config.authentication.jwt_issuer.?); + try std.testing.expect(config.authentication.jwt_audience != null); + try std.testing.expectEqualStrings("api", config.authentication.jwt_audience.?); +} + +test "config: round-trip - security config" { + const allocator = std.testing.allocator; + + var context = try schema_helpers.TestContext.init(allocator, "config-roundtrip-security"); + defer context.deinit(); + + const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-roundtrip-security.json" }); + defer allocator.free(temp_file_path); + const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-roundtrip-security.json" }); + defer allocator.free(schema_file_path); + + const config_content = try std.fmt.allocPrint(allocator, + \\{{ + \\ "security": {{ + \\ "allowedOrigins": ["https://example.com", "https://app.example.com"], + \\ "allowLocalhost": false, + \\ "maxMessagesPerSecond": 200, + \\ "maxConnections": 20, + \\ "maxMessageSize": 2097152 + \\ }}, + \\ "schema": "{s}" + \\}} + , .{schema_file_path}); + defer allocator.free(config_content); + + try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); + try std.fs.cwd().writeFile(.{ .sub_path = schema_file_path, .data = "{}" }); + + var config = try ConfigLoader.load(allocator, temp_file_path); + defer config.deinit(); + + // Verify values match original + try std.testing.expectEqual(@as(usize, 2), config.security.allowed_origins.len); + try std.testing.expectEqualStrings("https://example.com", config.security.allowed_origins[0]); + try std.testing.expectEqualStrings("https://app.example.com", config.security.allowed_origins[1]); + try std.testing.expectEqual(false, config.security.allow_localhost); + try std.testing.expectEqual(@as(u32, 200), config.security.max_messages_per_second); + try std.testing.expectEqual(@as(u32, 20), config.security.max_connections); + try std.testing.expectEqual(@as(usize, 2097152), config.security.max_message_size); +} + +test "config: round-trip - logging config" { + const allocator = std.testing.allocator; + + var context = try schema_helpers.TestContext.init(allocator, "config-roundtrip-logging"); + defer context.deinit(); + + const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-roundtrip-logging.json" }); + defer allocator.free(temp_file_path); + const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-roundtrip-logging.json" }); + defer allocator.free(schema_file_path); + + const config_content = try std.fmt.allocPrint(allocator, + \\{{ + \\ "logging": {{ + \\ "level": "debug", + \\ "format": "text" + \\ }}, + \\ "schema": "{s}" + \\}} + , .{schema_file_path}); + defer allocator.free(config_content); + + try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); + try std.fs.cwd().writeFile(.{ .sub_path = schema_file_path, .data = "{}" }); + + var config = try ConfigLoader.load(allocator, temp_file_path); + defer config.deinit(); + + // Verify values match original + try std.testing.expectEqual(Config.LoggingConfig.LogLevel.debug, config.logging.level); + try std.testing.expectEqual(Config.LoggingConfig.LogFormat.text, config.logging.format); +} + +test "config: round-trip - performance config" { + const allocator = std.testing.allocator; + + var context = try schema_helpers.TestContext.init(allocator, "config-roundtrip-perf"); + defer context.deinit(); + + const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-roundtrip-performance.json" }); + defer allocator.free(temp_file_path); + const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-roundtrip-performance.json" }); + defer allocator.free(schema_file_path); + + const config_content = try std.fmt.allocPrint(allocator, + \\{{ + \\ "performance": {{ + \\ "messageBufferSize": 2000, + \\ "batchWrites": false, + \\ "batchSize": 50, + \\ "batchTimeout": 20 + \\ }}, + \\ "schema": "{s}" + \\}} + , .{schema_file_path}); + defer allocator.free(config_content); + + try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); + try std.fs.cwd().writeFile(.{ .sub_path = schema_file_path, .data = "{}" }); + + var config = try ConfigLoader.load(allocator, temp_file_path); + defer config.deinit(); + + // Verify values match original + try std.testing.expectEqual(@as(usize, 2000), config.performance.message_buffer_size); + try std.testing.expectEqual(false, config.performance.batch_writes); + try std.testing.expectEqual(@as(usize, 50), config.performance.batch_size); + try std.testing.expectEqual(@as(u32, 20), config.performance.batch_timeout); +} + +test "config: round-trip - complete config" { + const allocator = std.testing.allocator; + + var context = try schema_helpers.TestContext.init(allocator, "config-roundtrip-complete"); + defer context.deinit(); + + const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-roundtrip-complete.json" }); + defer allocator.free(temp_file_path); + const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-roundtrip-complete.json" }); + defer allocator.free(schema_file_path); + + const config_content = try std.fmt.allocPrint(allocator, + \\{{ + \\ "server": {{ + \\ "port": 8080, + \\ "host": "127.0.0.1" + \\ }}, + \\ "authentication": {{ + \\ "jwt": {{ + \\ "secret": "my-secret-key", + \\ "algorithm": "HS512", + \\ "issuer": "zyncbase", + \\ "audience": "api" + \\ }} + \\ }}, + \\ "security": {{ + \\ "allowedOrigins": ["https://example.com"], + \\ "allowLocalhost": false, + \\ "maxMessagesPerSecond": 200, + \\ "maxConnections": 20, + \\ "maxMessageSize": 2097152 + \\ }}, + \\ "logging": {{ + \\ "level": "debug", + \\ "format": "text" + \\ }}, + \\ "performance": {{ + \\ "messageBufferSize": 2000, + \\ "batchWrites": false, + \\ "batchSize": 50, + \\ "batchTimeout": 20 + \\ }}, + \\ "dataDir": "{s}", + \\ "schema": "{s}" + \\}} + , .{ context.test_dir, schema_file_path }); + defer allocator.free(config_content); + + try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); + try std.fs.cwd().writeFile(.{ .sub_path = schema_file_path, .data = "{}" }); + + var config = try ConfigLoader.load(allocator, temp_file_path); + defer config.deinit(); + + // Verify all values match original + try std.testing.expectEqual(@as(u16, 8080), config.server.port); + try std.testing.expectEqualStrings("127.0.0.1", config.server.host); + + try std.testing.expect(config.authentication.jwt_secret != null); + try std.testing.expectEqualStrings("my-secret-key", config.authentication.jwt_secret.?); + try std.testing.expectEqualStrings("HS512", config.authentication.jwt_algorithm); + + try std.testing.expectEqual(@as(usize, 1), config.security.allowed_origins.len); + try std.testing.expectEqualStrings("https://example.com", config.security.allowed_origins[0]); + try std.testing.expectEqual(false, config.security.allow_localhost); + + try std.testing.expectEqual(Config.LoggingConfig.LogLevel.debug, config.logging.level); + try std.testing.expectEqual(Config.LoggingConfig.LogFormat.text, config.logging.format); + + try std.testing.expectEqual(@as(usize, 2000), config.performance.message_buffer_size); + try std.testing.expectEqual(false, config.performance.batch_writes); + try std.testing.expectEqual(@as(usize, 50), config.performance.batch_size); + try std.testing.expectEqual(@as(u32, 20), config.performance.batch_timeout); + + try std.testing.expectEqualStrings(context.test_dir, config.data_dir); +} diff --git a/src/logging_property_test.zig b/src/logging_test.zig similarity index 100% rename from src/logging_property_test.zig rename to src/logging_test.zig diff --git a/src/message_handler_property_test.zig b/src/message_handler_property_test.zig deleted file mode 100644 index 39fd6340..00000000 --- a/src/message_handler_property_test.zig +++ /dev/null @@ -1,273 +0,0 @@ -const std = @import("std"); - -const helpers = @import("app_test_helpers.zig"); -const msgpack = @import("msgpack_test_helpers.zig"); -const schema_types = @import("schema/types.zig"); -const store_helpers = @import("store_test_helpers.zig"); -const typed_doc_id = @import("typed/doc_id.zig"); - -const testing = std.testing; - -const AppTestContext = helpers.AppTestContext; -const routeWithArena = helpers.routeWithArena; - -const table_defs = [_]helpers.TableDef{ - .{ .name = "items", .fields = &.{ "value", "tags" } }, -}; - -const routeWithArenaOptional = helpers.routeWithArenaOptional; - -fn routeBytes(app: *AppTestContext, conn: anytype, allocator: std.mem.Allocator, message: []const u8) ![]u8 { - return try routeWithArena(&app.handler, allocator, conn, message); -} - -fn routeBytesOptional(app: *AppTestContext, conn: anytype, allocator: std.mem.Allocator, message: []const u8) !?[]u8 { - return try routeWithArenaOptional(&app.handler, allocator, conn, message); -} - -fn decodeResponse(allocator: std.mem.Allocator, response: []const u8) !msgpack.Payload { - var reader: std.Io.Reader = .fixed(response); - return try msgpack.decode(allocator, &reader); -} - -fn expectResponseType(allocator: std.mem.Allocator, response: []const u8, expected: []const u8) !void { - const parsed = try decodeResponse(allocator, response); - defer parsed.free(allocator); - - const value = (try msgpack.getMapValue(parsed, "type")) orelse return error.TestExpectedError; - try testing.expectEqualStrings(expected, value.str.value()); -} - -fn expectResponseId(allocator: std.mem.Allocator, response: []const u8, expected: u64) !void { - const parsed = try decodeResponse(allocator, response); - defer parsed.free(allocator); - - const value = (try msgpack.getMapValue(parsed, "id")) orelse return error.TestExpectedError; - try testing.expect(value == .uint); - try testing.expectEqual(expected, value.uint); -} - -fn expectErrorCode(allocator: std.mem.Allocator, response: []const u8, expected: []const u8) !void { - const parsed = try decodeResponse(allocator, response); - defer parsed.free(allocator); - - const resp_type = (try msgpack.getMapValue(parsed, "type")) orelse return error.TestExpectedError; - try testing.expectEqualStrings("error", resp_type.str.value()); - - const code = (try msgpack.getMapValue(parsed, "code")) orelse return error.TestExpectedError; - try testing.expectEqualStrings(expected, code.str.value()); -} - -test "message: representative frames route at protocol boundary" { - const allocator = testing.allocator; - var app: AppTestContext = undefined; - try app.init(allocator, "handler-property-route", &table_defs); - defer app.deinit(); - - const sc = try app.setupMockConnection(); - defer sc.deinit(); - const table = try app.tableMetadata("items"); - - { - const val = try store_helpers.createDocumentMapPayload(allocator, table, .{ - .{ "value", "value-a" }, - }); - defer val.free(allocator); - const message = try store_helpers.createStoreSetMessageWithPayload(allocator, 11, 1, table.index, 1, val); - defer allocator.free(message); - - const response = try routeBytes(&app, sc.conn, allocator, message); - defer allocator.free(response); - - try expectResponseType(allocator, response, "ok"); - try expectResponseId(allocator, response, 11); - } - - blk: { - const message = try store_helpers.createStoreQueryMessageWithEmptyFilter(allocator, 12, 1, table.index); - defer allocator.free(message); - - const response = (try routeBytesOptional(&app, sc.conn, allocator, message)) orelse break :blk; - defer allocator.free(response); - - try expectResponseType(allocator, response, "ok"); - try expectResponseId(allocator, response, 12); - } - - { - const message = try store_helpers.createCustomMessage(allocator, 13, "UnknownType", 1, table.index, &.{}); - defer allocator.free(message); - - const response = try routeBytes(&app, sc.conn, allocator, message); - defer allocator.free(response); - - try expectErrorCode(allocator, response, "INTERNAL_ERROR"); - try expectResponseId(allocator, response, 13); - } -} - -test "message: response id is preserved across routed requests" { - const allocator = testing.allocator; - var app: AppTestContext = undefined; - try app.init(allocator, "handler-property-correlation", &table_defs); - defer app.deinit(); - - const sc = try app.setupMockConnection(); - defer sc.deinit(); - const table = try app.tableMetadata("items"); - - { - const val = try store_helpers.createDocumentMapPayload(allocator, table, .{ - .{ "value", "value-b" }, - }); - defer val.free(allocator); - const message = try store_helpers.createStoreSetMessageWithPayload(allocator, 101, 1, table.index, 1, val); - defer allocator.free(message); - - const response = try routeBytes(&app, sc.conn, allocator, message); - defer allocator.free(response); - try expectResponseId(allocator, response, 101); - } - - blk: { - const message = try store_helpers.createStoreQueryMessageWithEmptyFilter(allocator, 202, 1, table.index); - defer allocator.free(message); - - const response = (try routeBytesOptional(&app, sc.conn, allocator, message)) orelse break :blk; - defer allocator.free(response); - try expectResponseId(allocator, response, 202); - } - - { - const message = try store_helpers.createCustomMessage(allocator, 303, "InvalidType", 1, table.index, &.{}); - defer allocator.free(message); - - const response = try routeBytes(&app, sc.conn, allocator, message); - defer allocator.free(response); - try expectResponseId(allocator, response, 303); - } -} - -test "message: invalid envelopes fail before store dispatch" { - const allocator = testing.allocator; - var app: AppTestContext = undefined; - try app.init(allocator, "handler-property-invalid-envelope", &table_defs); - defer app.deinit(); - - const sc = try app.setupMockConnection(); - defer sc.deinit(); - - const missing_id = try store_helpers.createInvalidStoreSetMessageMissingId(allocator, 1); - defer allocator.free(missing_id); - - try testing.expectError(error.MissingRequiredFields, routeWithArena(&app.handler, allocator, sc.conn, missing_id)); -} - -test "message: repeated routed requests release per-message allocations" { - const allocator = testing.allocator; - var app: AppTestContext = undefined; - try app.init(allocator, "handler-property-lifetime", &table_defs); - defer app.deinit(); - - const sc = try app.setupMockConnection(); - defer sc.deinit(); - const table = try app.tableMetadata("items"); - - var i: usize = 0; - while (i < 32) : (i += 1) { - const msg_id: u64 = @intCast(i + 1); - const doc_id: typed_doc_id.DocId = @intCast(i + 1); - const val = try store_helpers.createDocumentMapPayload(allocator, table, .{ - .{ "value", "value-c" }, - }); - defer val.free(allocator); - const message = try store_helpers.createStoreSetMessageWithPayload(allocator, msg_id, 1, table.index, doc_id, val); - defer allocator.free(message); - - const response = try routeBytes(&app, sc.conn, allocator, message); - defer allocator.free(response); - - try expectResponseType(allocator, response, "ok"); - try expectResponseId(allocator, response, msg_id); - } -} - -test "message: concurrent routed requests release response allocations" { - const allocator = testing.allocator; - var app: AppTestContext = undefined; - try app.init(allocator, "handler-property-concurrent-lifetime", &table_defs); - defer app.deinit(); - - const table = try app.tableMetadata("items"); - - const ThreadContext = struct { - app: *AppTestContext, - table_index: usize, - table: *const schema_types.Table, - thread_index: usize, - iterations: usize, - failure: ?anyerror = null, - - fn run(ctx: *@This()) void { - runInternal(ctx) catch |err| { - std.log.err("message routing property failed: {}", .{err}); - ctx.failure = err; - }; - } - - fn runInternal(ctx: *@This()) !void { - const thread_allocator = ctx.app.allocator; - const sc = try ctx.app.setupMockConnection(); - defer sc.deinit(); - - var i: usize = 0; - while (i < ctx.iterations) : (i += 1) { - const raw_id = ctx.thread_index * 1000 + i + 1; - const msg_id: u64 = @intCast(raw_id); - const doc_id: typed_doc_id.DocId = @intCast(raw_id); - - const val = try store_helpers.createDocumentMapPayload(thread_allocator, ctx.table, .{ - .{ "value", "value-d" }, - }); - defer val.free(thread_allocator); - const message = try store_helpers.createStoreSetMessageWithPayload( - thread_allocator, - msg_id, - 1, - ctx.table_index, - doc_id, - val, - ); - defer thread_allocator.free(message); - - const response = try routeBytes(ctx.app, sc.conn, thread_allocator, message); - defer thread_allocator.free(response); - - try expectResponseType(thread_allocator, response, "ok"); - try expectResponseId(thread_allocator, response, msg_id); - } - } - }; - - var contexts: [4]ThreadContext = undefined; - var threads: [4]std.Thread = undefined; - - for (&contexts, 0..) |*ctx, idx| { - ctx.* = .{ - .app = &app, - .table_index = table.index, - .table = table, - .thread_index = idx, - .iterations = 8, - }; - threads[idx] = try std.Thread.spawn(.{}, ThreadContext.run, .{ctx}); - } - - for (threads) |thread| { - thread.join(); - } - - for (contexts) |ctx| { - if (ctx.failure) |err| return err; - } -} diff --git a/src/message_handler_test.zig b/src/message_handler_test.zig index e0a6f3ff..668c610f 100644 --- a/src/message_handler_test.zig +++ b/src/message_handler_test.zig @@ -1,9 +1,12 @@ const std = @import("std"); const helpers = @import("app_test_helpers.zig"); +const msgpack_helpers = @import("msgpack_test_helpers.zig"); const msgpack = @import("msgpack_utils.zig"); +const schema_types = @import("schema/types.zig"); const sth = @import("storage_engine_test_helpers.zig"); const store_helpers = @import("store_test_helpers.zig"); +const typed_doc_id = @import("typed/doc_id.zig"); const WebSocket = @import("uwebsockets_wrapper.zig").WebSocket; const testing = std.testing; @@ -487,3 +490,264 @@ test "NamespaceSwitch: namespaced=false allows any switch" { defer if (result2.code) |code| allocator.free(code); try testing.expectEqualStrings("ok", result2.resp_type); } + +const table_defs = [_]helpers.TableDef{ + .{ .name = "items", .fields = &.{ "value", "tags" } }, +}; + +const routeWithArenaOptional = helpers.routeWithArenaOptional; + +fn routeBytes(app: *AppTestContext, conn: anytype, allocator: std.mem.Allocator, message: []const u8) ![]u8 { + return try routeWithArena(&app.handler, allocator, conn, message); +} + +fn routeBytesOptional(app: *AppTestContext, conn: anytype, allocator: std.mem.Allocator, message: []const u8) !?[]u8 { + return try routeWithArenaOptional(&app.handler, allocator, conn, message); +} + +fn decodeResponse(allocator: std.mem.Allocator, response: []const u8) !msgpack_helpers.Payload { + var reader: std.Io.Reader = .fixed(response); + return try msgpack_helpers.decode(allocator, &reader); +} + +fn expectResponseType(allocator: std.mem.Allocator, response: []const u8, expected: []const u8) !void { + const parsed = try decodeResponse(allocator, response); + defer parsed.free(allocator); + + const value = (try msgpack_helpers.getMapValue(parsed, "type")) orelse return error.TestExpectedError; + try testing.expectEqualStrings(expected, value.str.value()); +} + +fn expectResponseId(allocator: std.mem.Allocator, response: []const u8, expected: u64) !void { + const parsed = try decodeResponse(allocator, response); + defer parsed.free(allocator); + + const value = (try msgpack_helpers.getMapValue(parsed, "id")) orelse return error.TestExpectedError; + try testing.expect(value == .uint); + try testing.expectEqual(expected, value.uint); +} + +fn expectErrorCode(allocator: std.mem.Allocator, response: []const u8, expected: []const u8) !void { + const parsed = try decodeResponse(allocator, response); + defer parsed.free(allocator); + + const resp_type = (try msgpack_helpers.getMapValue(parsed, "type")) orelse return error.TestExpectedError; + try testing.expectEqualStrings("error", resp_type.str.value()); + + const code = (try msgpack_helpers.getMapValue(parsed, "code")) orelse return error.TestExpectedError; + try testing.expectEqualStrings(expected, code.str.value()); +} + +test "message: representative frames route at protocol boundary" { + const allocator = testing.allocator; + var app: AppTestContext = undefined; + try app.init(allocator, "handler-property-route", &table_defs); + defer app.deinit(); + + const sc = try app.setupMockConnection(); + defer sc.deinit(); + const table = try app.tableMetadata("items"); + + { + const val = try store_helpers.createDocumentMapPayload(allocator, table, .{ + .{ "value", "value-a" }, + }); + defer val.free(allocator); + const message = try store_helpers.createStoreSetMessageWithPayload(allocator, 11, 1, table.index, 1, val); + defer allocator.free(message); + + const response = try routeBytes(&app, sc.conn, allocator, message); + defer allocator.free(response); + + try expectResponseType(allocator, response, "ok"); + try expectResponseId(allocator, response, 11); + } + + blk: { + const message = try store_helpers.createStoreQueryMessageWithEmptyFilter(allocator, 12, 1, table.index); + defer allocator.free(message); + + const response = (try routeBytesOptional(&app, sc.conn, allocator, message)) orelse break :blk; + defer allocator.free(response); + + try expectResponseType(allocator, response, "ok"); + try expectResponseId(allocator, response, 12); + } + + { + const message = try store_helpers.createCustomMessage(allocator, 13, "UnknownType", 1, table.index, &.{}); + defer allocator.free(message); + + const response = try routeBytes(&app, sc.conn, allocator, message); + defer allocator.free(response); + + try expectErrorCode(allocator, response, "INTERNAL_ERROR"); + try expectResponseId(allocator, response, 13); + } +} + +test "message: response id is preserved across routed requests" { + const allocator = testing.allocator; + var app: AppTestContext = undefined; + try app.init(allocator, "handler-property-correlation", &table_defs); + defer app.deinit(); + + const sc = try app.setupMockConnection(); + defer sc.deinit(); + const table = try app.tableMetadata("items"); + + { + const val = try store_helpers.createDocumentMapPayload(allocator, table, .{ + .{ "value", "value-b" }, + }); + defer val.free(allocator); + const message = try store_helpers.createStoreSetMessageWithPayload(allocator, 101, 1, table.index, 1, val); + defer allocator.free(message); + + const response = try routeBytes(&app, sc.conn, allocator, message); + defer allocator.free(response); + try expectResponseId(allocator, response, 101); + } + + blk: { + const message = try store_helpers.createStoreQueryMessageWithEmptyFilter(allocator, 202, 1, table.index); + defer allocator.free(message); + + const response = (try routeBytesOptional(&app, sc.conn, allocator, message)) orelse break :blk; + defer allocator.free(response); + try expectResponseId(allocator, response, 202); + } + + { + const message = try store_helpers.createCustomMessage(allocator, 303, "InvalidType", 1, table.index, &.{}); + defer allocator.free(message); + + const response = try routeBytes(&app, sc.conn, allocator, message); + defer allocator.free(response); + try expectResponseId(allocator, response, 303); + } +} + +test "message: invalid envelopes fail before store dispatch" { + const allocator = testing.allocator; + var app: AppTestContext = undefined; + try app.init(allocator, "handler-property-invalid-envelope", &table_defs); + defer app.deinit(); + + const sc = try app.setupMockConnection(); + defer sc.deinit(); + + const missing_id = try store_helpers.createInvalidStoreSetMessageMissingId(allocator, 1); + defer allocator.free(missing_id); + + try testing.expectError(error.MissingRequiredFields, routeWithArena(&app.handler, allocator, sc.conn, missing_id)); +} + +test "message: repeated routed requests release per-message allocations" { + const allocator = testing.allocator; + var app: AppTestContext = undefined; + try app.init(allocator, "handler-property-lifetime", &table_defs); + defer app.deinit(); + + const sc = try app.setupMockConnection(); + defer sc.deinit(); + const table = try app.tableMetadata("items"); + + var i: usize = 0; + while (i < 32) : (i += 1) { + const msg_id: u64 = @intCast(i + 1); + const doc_id: typed_doc_id.DocId = @intCast(i + 1); + const val = try store_helpers.createDocumentMapPayload(allocator, table, .{ + .{ "value", "value-c" }, + }); + defer val.free(allocator); + const message = try store_helpers.createStoreSetMessageWithPayload(allocator, msg_id, 1, table.index, doc_id, val); + defer allocator.free(message); + + const response = try routeBytes(&app, sc.conn, allocator, message); + defer allocator.free(response); + + try expectResponseType(allocator, response, "ok"); + try expectResponseId(allocator, response, msg_id); + } +} + +test "message: concurrent routed requests release response allocations" { + const allocator = testing.allocator; + var app: AppTestContext = undefined; + try app.init(allocator, "handler-property-concurrent-lifetime", &table_defs); + defer app.deinit(); + + const table = try app.tableMetadata("items"); + + const ThreadContext = struct { + app: *AppTestContext, + table_index: usize, + table: *const schema_types.Table, + thread_index: usize, + iterations: usize, + failure: ?anyerror = null, + + fn run(ctx: *@This()) void { + runInternal(ctx) catch |err| { + std.log.err("message routing property failed: {}", .{err}); + ctx.failure = err; + }; + } + + fn runInternal(ctx: *@This()) !void { + const thread_allocator = ctx.app.allocator; + const sc = try ctx.app.setupMockConnection(); + defer sc.deinit(); + + var i: usize = 0; + while (i < ctx.iterations) : (i += 1) { + const raw_id = ctx.thread_index * 1000 + i + 1; + const msg_id: u64 = @intCast(raw_id); + const doc_id: typed_doc_id.DocId = @intCast(raw_id); + + const val = try store_helpers.createDocumentMapPayload(thread_allocator, ctx.table, .{ + .{ "value", "value-d" }, + }); + defer val.free(thread_allocator); + const message = try store_helpers.createStoreSetMessageWithPayload( + thread_allocator, + msg_id, + 1, + ctx.table_index, + doc_id, + val, + ); + defer thread_allocator.free(message); + + const response = try routeBytes(ctx.app, sc.conn, thread_allocator, message); + defer thread_allocator.free(response); + + try expectResponseType(thread_allocator, response, "ok"); + try expectResponseId(thread_allocator, response, msg_id); + } + } + }; + + var contexts: [4]ThreadContext = undefined; + var threads: [4]std.Thread = undefined; + + for (&contexts, 0..) |*ctx, idx| { + ctx.* = .{ + .app = &app, + .table_index = table.index, + .table = table, + .thread_index = idx, + .iterations = 8, + }; + threads[idx] = try std.Thread.spawn(.{}, ThreadContext.run, .{ctx}); + } + + for (threads) |thread| { + thread.join(); + } + + for (contexts) |ctx| { + if (ctx.failure) |err| return err; + } +} diff --git a/src/migration_detector_property_test.zig b/src/migration_detector_test.zig similarity index 100% rename from src/migration_detector_property_test.zig rename to src/migration_detector_test.zig diff --git a/src/migration_executor_property_test.zig b/src/migration_executor_property_test.zig deleted file mode 100644 index 9ff3cd4b..00000000 --- a/src/migration_executor_property_test.zig +++ /dev/null @@ -1,397 +0,0 @@ -const std = @import("std"); - -const sqlite = @import("sqlite"); - -const migration_detector = @import("migration_detector.zig"); -const migration_executor = @import("migration_executor.zig"); -const schema_helpers = @import("schema/test_helpers.zig"); -const ddl_generator = @import("sql/ddl.zig"); - -const MigrationExecutor = migration_executor.MigrationExecutor; - -const zero_doc_id = [_]u8{0} ** 16; - -fn openMemDb() !sqlite.Db { - return sqlite.Db.init(.{ - .mode = .Memory, - .open_flags = .{ .write = true, .create = true }, - }); -} - -fn execSql(db: *sqlite.Db, allocator: std.mem.Allocator, sql: []const u8) !void { - const sql_z = try allocator.dupeZ(u8, sql); - defer allocator.free(sql_z); - try db.execDynamic(sql_z, .{}, .{}); -} - -fn execMultiSql(db: *sqlite.Db, allocator: std.mem.Allocator, sql: []const u8) !void { - const sql_z = try allocator.dupeZ(u8, sql); - defer allocator.free(sql_z); - try db.execMulti(sql_z, .{}); -} - -fn insertSchemaMetaVersion(db: *sqlite.Db, allocator: std.mem.Allocator, version: []const u8) !void { - try db.exec("CREATE TABLE IF NOT EXISTS schema_meta (version TEXT NOT NULL, applied_at INTEGER NOT NULL)", .{}, .{}); - try db.exec("DELETE FROM schema_meta", .{}, .{}); - const sql = try std.fmt.allocPrint(allocator, "INSERT INTO schema_meta (version, applied_at) VALUES ('{s}', 0)", .{version}); - defer allocator.free(sql); - try execSql(db, allocator, sql); -} - -// Feature: schema-aware-storage, Property 10: Additive migration preserves existing data -// For any database state and additive MigrationPlan (containing only create_table and add_column -// changes), after Migration_Executor.execute completes, every row that existed before the migration -// SHALL still exist with all its original column values intact. -test "migration_executor: additive migration preserves existing data" { - const allocator = std.testing.allocator; - - var prng = std.Random.DefaultPrng.init(42); - const rand = prng.random(); - - const table_names = [_][]const u8{ "posts", "items", "orders", "tags", "comments" }; - - var iter: usize = 0; - while (iter < 100) : (iter += 1) { - var db = try openMemDb(); - defer db.deinit(); - - var gen = ddl_generator.DDLGenerator.init(allocator); - const tname = table_names[rand.intRangeAtMost(usize, 0, table_names.len - 1)]; - - // Create table with initial columns - var initial_table = schema_helpers.makeSingleRuntimeTable(allocator, tname, &[_]schema_helpers.TestFieldDef{ - .{ .name = "title", .field_type = .text }, - }); - defer initial_table.deinit(allocator); - const initial_ddl = try gen.generateDDL(initial_table); - defer allocator.free(initial_ddl); - try execMultiSql(&db, allocator, initial_ddl); - - // Insert a row - const row_title = "hello world"; - const insert_sql = try std.fmt.allocPrint( - allocator, - "INSERT INTO {s} (id, namespace_id, owner_id, title, created_at, updated_at) VALUES (zeroblob(16), 1, zeroblob(16), '{s}', 0, 0)", - .{ tname, row_title }, - ); - defer allocator.free(insert_sql); - try execSql(&db, allocator, insert_sql); - - // Target schema has both columns - var target_table = schema_helpers.makeSingleRuntimeTable(allocator, tname, &[_]schema_helpers.TestFieldDef{ - .{ .name = "title", .field_type = .text }, - .{ .name = "score", .field_type = .integer }, - }); - defer target_table.deinit(allocator); - const target_version = "1.0.0"; - - // Build additive migration plan: add a new column - const new_field = schema_helpers.makeField("score", .integer); - - const changes = try allocator.alloc(migration_detector.Change, 1); - defer allocator.free(changes); - changes[0] = .{ - .kind = .add_column, - .table = &target_table, - .field = new_field, - }; - - const plan = migration_detector.MigrationPlan{ - .changes = changes, - .is_destructive = false, - }; - - var executor = MigrationExecutor.init(allocator, &db, &gen, .{ - .auto_migrate = .full, - .allow_destructive = false, - }); - - try executor.execute(plan, target_version); - - // Verify the original row still exists with original values - const check_sql = try std.fmt.allocPrint( - allocator, - "SELECT id, title FROM {s} WHERE id = zeroblob(16)", - .{tname}, - ); - defer allocator.free(check_sql); - const check_sql_z = try allocator.dupeZ(u8, check_sql); - defer allocator.free(check_sql_z); - - var stmt = try db.prepareDynamic(check_sql_z); - defer stmt.deinit(); - - const CheckRow = struct { - id: []const u8, - title: []const u8, - }; - var check_iter = try stmt.iteratorAlloc(CheckRow, allocator, .{}); - const row = try check_iter.nextAlloc(allocator, .{}); - try std.testing.expect(row != null); - defer { - allocator.free(row.?.id); - allocator.free(row.?.title); - } - try std.testing.expectEqualSlices(u8, &zero_doc_id, row.?.id); - try std.testing.expectEqualStrings(row_title, row.?.title); - } -} - -// Feature: schema-aware-storage, Property 11: Destructive migration is refused when not allowed -// For any MigrationPlan that contains at least one change_type or remove_column change, -// when MigrationConfig.allow_destructive is false, Migration_Executor.execute SHALL return -// an error and SHALL NOT modify the database. -test "migration_executor: destructive migration refused when not allowed" { - const allocator = std.testing.allocator; - - var prng = std.Random.DefaultPrng.init(77); - const rand = prng.random(); - - const table_names = [_][]const u8{ "alpha", "beta", "gamma", "delta", "epsilon" }; - const destructive_kinds = [_]migration_detector.ChangeKind{ .change_type, .remove_column }; - - var iter: usize = 0; - while (iter < 100) : (iter += 1) { - var db = try openMemDb(); - defer db.deinit(); - - var gen = ddl_generator.DDLGenerator.init(allocator); - const tname = table_names[rand.intRangeAtMost(usize, 0, table_names.len - 1)]; - - // Create table - var table = schema_helpers.makeSingleRuntimeTable(allocator, tname, &[_]schema_helpers.TestFieldDef{ - .{ .name = "col_a", .field_type = .text }, - }); - defer table.deinit(allocator); - const ddl = try gen.generateDDL(table); - defer allocator.free(ddl); - try execMultiSql(&db, allocator, ddl); - - // Insert a row to verify DB is unchanged after refused migration - const insert_sql = try std.fmt.allocPrint( - allocator, - "INSERT INTO {s} (id, namespace_id, owner_id, col_a, created_at, updated_at) VALUES (zeroblob(16), 1, zeroblob(16), 'val1', 0, 0)", - .{tname}, - ); - defer allocator.free(insert_sql); - try execSql(&db, allocator, insert_sql); - - // Build destructive plan - const kind = destructive_kinds[rand.intRangeAtMost(usize, 0, destructive_kinds.len - 1)]; - - var target_table = schema_helpers.makeSingleRuntimeTable(allocator, tname, &[_]schema_helpers.TestFieldDef{ - .{ .name = "col_a", .field_type = .integer }, - }); - defer target_table.deinit(allocator); - const target_version = "1.0.0"; - - const changes = try allocator.alloc(migration_detector.Change, 1); - defer allocator.free(changes); - changes[0] = .{ - .kind = kind, - .table = &target_table, - .field = schema_helpers.makeField("col_a", .integer), - }; - - const plan = migration_detector.MigrationPlan{ - .changes = changes, - .is_destructive = true, - }; - - var executor = MigrationExecutor.init(allocator, &db, &gen, .{ - .auto_migrate = .additive_only, - .allow_destructive = false, - }); - - const result = executor.execute(plan, target_version); - try std.testing.expectError(error.DestructiveMigrationNotAllowed, result); - - // Verify DB is unchanged: original row still exists - const check_sql = try std.fmt.allocPrint( - allocator, - "SELECT id FROM {s} WHERE id = zeroblob(16)", - .{tname}, - ); - defer allocator.free(check_sql); - const check_sql_z = try allocator.dupeZ(u8, check_sql); - defer allocator.free(check_sql_z); - - var stmt = try db.prepareDynamic(check_sql_z); - defer stmt.deinit(); - - const IdRow = struct { id: []const u8 }; - var check_iter = try stmt.iteratorAlloc(IdRow, allocator, .{}); - const row = try check_iter.nextAlloc(allocator, .{}); - try std.testing.expect(row != null); - defer allocator.free(row.?.id); - try std.testing.expectEqualSlices(u8, &zero_doc_id, row.?.id); - } -} - -// Feature: schema-aware-storage, Property 12: Schema version is persisted after migration -// For any successful migration, querying schema_meta immediately after -// Migration_Executor.execute returns SHALL yield a row whose version column equals -// the version string from the target Schema. -test "migration_executor: schema version persisted after migration" { - const allocator = std.testing.allocator; - - var prng = std.Random.DefaultPrng.init(55); - const rand = prng.random(); - - const table_names = [_][]const u8{ "things", "stuff", "items", "records", "entries" }; - const versions = [_][]const u8{ "1.0.0", "1.1.0", "1.2.3", "2.0.0", "0.1.0" }; - - var iter: usize = 0; - while (iter < 100) : (iter += 1) { - var db = try openMemDb(); - defer db.deinit(); - - var gen = ddl_generator.DDLGenerator.init(allocator); - const tname = table_names[rand.intRangeAtMost(usize, 0, table_names.len - 1)]; - const version = versions[rand.intRangeAtMost(usize, 0, versions.len - 1)]; - - // Build a create_table plan - var target_table = schema_helpers.makeSingleRuntimeTable(allocator, tname, &[_]schema_helpers.TestFieldDef{ - .{ .name = "name", .field_type = .text }, - }); - defer target_table.deinit(allocator); - const target_version = version; - - const changes = try allocator.alloc(migration_detector.Change, 1); - defer allocator.free(changes); - changes[0] = .{ - .kind = .create_table, - .table = &target_table, - .field = null, - }; - - const plan = migration_detector.MigrationPlan{ - .changes = changes, - .is_destructive = false, - }; - - var executor = MigrationExecutor.init(allocator, &db, &gen, .{ - .auto_migrate = .full, - .allow_destructive = false, - }); - - try executor.execute(plan, target_version); - - // Verify schema_meta has the correct version - var stmt = try db.prepare("SELECT version FROM schema_meta LIMIT 1"); - defer stmt.deinit(); - - const VersionRow = struct { version: []const u8 }; - var ver_iter = try stmt.iteratorAlloc(VersionRow, allocator, .{}); - const ver_row = try ver_iter.nextAlloc(allocator, .{}); - try std.testing.expect(ver_row != null); - defer allocator.free(ver_row.?.version); - try std.testing.expectEqualStrings(version, ver_row.?.version); - } -} - -// Feature: schema-aware-storage, Property 20: Major version bump is refused -// For any database whose persisted major version component is less than the major version -// component in the target Schema, Migration_Executor.execute SHALL return an error and -// SHALL NOT apply any changes. -test "migration_executor: major version bump is refused" { - const allocator = std.testing.allocator; - - var prng = std.Random.DefaultPrng.init(33); - const rand = prng.random(); - - const table_names = [_][]const u8{ "docs", "notes", "files", "blobs", "chunks" }; - - var iter: usize = 0; - while (iter < 100) : (iter += 1) { - var db = try openMemDb(); - defer db.deinit(); - - var gen = ddl_generator.DDLGenerator.init(allocator); - const tname = table_names[rand.intRangeAtMost(usize, 0, table_names.len - 1)]; - - // Create table and insert persisted version with lower major - var table = schema_helpers.makeSingleRuntimeTable(allocator, tname, &[_]schema_helpers.TestFieldDef{ - .{ .name = "data", .field_type = .text }, - }); - defer table.deinit(allocator); - const ddl = try gen.generateDDL(table); - defer allocator.free(ddl); - try execMultiSql(&db, allocator, ddl); - - // Persisted version: major = 1 - const persisted_major = rand.intRangeAtMost(u32, 1, 5); - const target_major = persisted_major + 1 + rand.intRangeAtMost(u32, 0, 3); - - const persisted_ver = try std.fmt.allocPrint(allocator, "{d}.0.0", .{persisted_major}); - defer allocator.free(persisted_ver); - const target_ver = try std.fmt.allocPrint(allocator, "{d}.0.0", .{target_major}); - defer allocator.free(target_ver); - - try insertSchemaMetaVersion(&db, allocator, persisted_ver); - - // Build a simple add_column plan - var target_table = schema_helpers.makeSingleRuntimeTable(allocator, tname, &[_]schema_helpers.TestFieldDef{ - .{ .name = "data", .field_type = .text }, - .{ .name = "extra", .field_type = .text }, - }); - defer target_table.deinit(allocator); - const target_version = target_ver; - - const changes = try allocator.alloc(migration_detector.Change, 1); - defer allocator.free(changes); - changes[0] = .{ - .kind = .add_column, - .table = &target_table, - .field = schema_helpers.makeField("extra", .text), - }; - - const plan = migration_detector.MigrationPlan{ - .changes = changes, - .is_destructive = false, - }; - - var executor = MigrationExecutor.init(allocator, &db, &gen, .{ - .auto_migrate = .full, - .allow_destructive = false, - }); - - const result = executor.execute(plan, target_version); - try std.testing.expectError(error.MajorVersionBumpNotAllowed, result); - - // Verify no changes were applied: column 'extra' should not exist - // Check via PRAGMA table_info - 'extra' column should not be present - const pragma_sql = try std.fmt.allocPrint( - allocator, - "PRAGMA table_info({s})", - .{tname}, - ); - defer allocator.free(pragma_sql); - - var pragma_stmt = try db.prepareDynamic(pragma_sql); - defer pragma_stmt.deinit(); - - const PragmaRow = struct { - cid: i64, - name: []const u8, - type: []const u8, - notnull: i64, - dflt_value: ?[]const u8, - pk: i64, - }; - - var found_extra = false; - var pragma_iter = try pragma_stmt.iteratorAlloc(PragmaRow, allocator, .{}); - while (try pragma_iter.nextAlloc(allocator, .{})) |row| { - defer { - allocator.free(row.name); - allocator.free(row.type); - if (row.dflt_value) |dv| allocator.free(dv); - } - if (std.mem.eql(u8, row.name, "extra")) { - found_extra = true; - } - } - try std.testing.expect(!found_extra); - } -} diff --git a/src/migration_executor_test.zig b/src/migration_executor_test.zig index 191676ca..68583a56 100644 --- a/src/migration_executor_test.zig +++ b/src/migration_executor_test.zig @@ -211,3 +211,369 @@ test "migration_executor: 5.8 - unparseable version in schema_meta halts startup const result = executor.execute(plan, target_version); try std.testing.expectError(error.InvalidVersion, result); } + +fn insertSchemaMetaVersion(db: *sqlite.Db, allocator: std.mem.Allocator, version: []const u8) !void { + try db.exec("CREATE TABLE IF NOT EXISTS schema_meta (version TEXT NOT NULL, applied_at INTEGER NOT NULL)", .{}, .{}); + try db.exec("DELETE FROM schema_meta", .{}, .{}); + const sql = try std.fmt.allocPrint(allocator, "INSERT INTO schema_meta (version, applied_at) VALUES ('{s}', 0)", .{version}); + defer allocator.free(sql); + try execSql(db, allocator, sql); +} + +// Feature: schema-aware-storage, Property 10: Additive migration preserves existing data +// For any database state and additive MigrationPlan (containing only create_table and add_column +// changes), after Migration_Executor.execute completes, every row that existed before the migration +// SHALL still exist with all its original column values intact. +test "migration_executor: additive migration preserves existing data" { + const allocator = std.testing.allocator; + + var prng = std.Random.DefaultPrng.init(42); + const rand = prng.random(); + + const table_names = [_][]const u8{ "posts", "items", "orders", "tags", "comments" }; + + var iter: usize = 0; + while (iter < 100) : (iter += 1) { + var db = try openMemDb(); + defer db.deinit(); + + var gen = ddl_generator.DDLGenerator.init(allocator); + const tname = table_names[rand.intRangeAtMost(usize, 0, table_names.len - 1)]; + + // Create table with initial columns + var initial_table = schema_helpers.makeSingleRuntimeTable(allocator, tname, &[_]schema_helpers.TestFieldDef{ + .{ .name = "title", .field_type = .text }, + }); + defer initial_table.deinit(allocator); + const initial_ddl = try gen.generateDDL(initial_table); + defer allocator.free(initial_ddl); + try execMultiSql(&db, allocator, initial_ddl); + + // Insert a row + const row_title = "hello world"; + const insert_sql = try std.fmt.allocPrint( + allocator, + "INSERT INTO {s} (id, namespace_id, owner_id, title, created_at, updated_at) VALUES (zeroblob(16), 1, zeroblob(16), '{s}', 0, 0)", + .{ tname, row_title }, + ); + defer allocator.free(insert_sql); + try execSql(&db, allocator, insert_sql); + + // Target schema has both columns + var target_table = schema_helpers.makeSingleRuntimeTable(allocator, tname, &[_]schema_helpers.TestFieldDef{ + .{ .name = "title", .field_type = .text }, + .{ .name = "score", .field_type = .integer }, + }); + defer target_table.deinit(allocator); + const target_version = "1.0.0"; + + // Build additive migration plan: add a new column + const new_field = schema_helpers.makeField("score", .integer); + + const changes = try allocator.alloc(migration_detector.Change, 1); + defer allocator.free(changes); + changes[0] = .{ + .kind = .add_column, + .table = &target_table, + .field = new_field, + }; + + const plan = migration_detector.MigrationPlan{ + .changes = changes, + .is_destructive = false, + }; + + var executor = MigrationExecutor.init(allocator, &db, &gen, .{ + .auto_migrate = .full, + .allow_destructive = false, + }); + + try executor.execute(plan, target_version); + + // Verify the original row still exists with original values + const check_sql = try std.fmt.allocPrint( + allocator, + "SELECT id, title FROM {s} WHERE id = zeroblob(16)", + .{tname}, + ); + defer allocator.free(check_sql); + const check_sql_z = try allocator.dupeZ(u8, check_sql); + defer allocator.free(check_sql_z); + + var stmt = try db.prepareDynamic(check_sql_z); + defer stmt.deinit(); + + const CheckRow = struct { + id: []const u8, + title: []const u8, + }; + var check_iter = try stmt.iteratorAlloc(CheckRow, allocator, .{}); + const row = try check_iter.nextAlloc(allocator, .{}); + try std.testing.expect(row != null); + defer { + allocator.free(row.?.id); + allocator.free(row.?.title); + } + try std.testing.expectEqualSlices(u8, &zero_doc_id, row.?.id); + try std.testing.expectEqualStrings(row_title, row.?.title); + } +} + +// Feature: schema-aware-storage, Property 11: Destructive migration is refused when not allowed +// For any MigrationPlan that contains at least one change_type or remove_column change, +// when MigrationConfig.allow_destructive is false, Migration_Executor.execute SHALL return +// an error and SHALL NOT modify the database. +test "migration_executor: destructive migration refused when not allowed" { + const allocator = std.testing.allocator; + + var prng = std.Random.DefaultPrng.init(77); + const rand = prng.random(); + + const table_names = [_][]const u8{ "alpha", "beta", "gamma", "delta", "epsilon" }; + const destructive_kinds = [_]migration_detector.ChangeKind{ .change_type, .remove_column }; + + var iter: usize = 0; + while (iter < 100) : (iter += 1) { + var db = try openMemDb(); + defer db.deinit(); + + var gen = ddl_generator.DDLGenerator.init(allocator); + const tname = table_names[rand.intRangeAtMost(usize, 0, table_names.len - 1)]; + + // Create table + var table = schema_helpers.makeSingleRuntimeTable(allocator, tname, &[_]schema_helpers.TestFieldDef{ + .{ .name = "col_a", .field_type = .text }, + }); + defer table.deinit(allocator); + const ddl = try gen.generateDDL(table); + defer allocator.free(ddl); + try execMultiSql(&db, allocator, ddl); + + // Insert a row to verify DB is unchanged after refused migration + const insert_sql = try std.fmt.allocPrint( + allocator, + "INSERT INTO {s} (id, namespace_id, owner_id, col_a, created_at, updated_at) VALUES (zeroblob(16), 1, zeroblob(16), 'val1', 0, 0)", + .{tname}, + ); + defer allocator.free(insert_sql); + try execSql(&db, allocator, insert_sql); + + // Build destructive plan + const kind = destructive_kinds[rand.intRangeAtMost(usize, 0, destructive_kinds.len - 1)]; + + var target_table = schema_helpers.makeSingleRuntimeTable(allocator, tname, &[_]schema_helpers.TestFieldDef{ + .{ .name = "col_a", .field_type = .integer }, + }); + defer target_table.deinit(allocator); + const target_version = "1.0.0"; + + const changes = try allocator.alloc(migration_detector.Change, 1); + defer allocator.free(changes); + changes[0] = .{ + .kind = kind, + .table = &target_table, + .field = schema_helpers.makeField("col_a", .integer), + }; + + const plan = migration_detector.MigrationPlan{ + .changes = changes, + .is_destructive = true, + }; + + var executor = MigrationExecutor.init(allocator, &db, &gen, .{ + .auto_migrate = .additive_only, + .allow_destructive = false, + }); + + const result = executor.execute(plan, target_version); + try std.testing.expectError(error.DestructiveMigrationNotAllowed, result); + + // Verify DB is unchanged: original row still exists + const check_sql = try std.fmt.allocPrint( + allocator, + "SELECT id FROM {s} WHERE id = zeroblob(16)", + .{tname}, + ); + defer allocator.free(check_sql); + const check_sql_z = try allocator.dupeZ(u8, check_sql); + defer allocator.free(check_sql_z); + + var stmt = try db.prepareDynamic(check_sql_z); + defer stmt.deinit(); + + const IdRow = struct { id: []const u8 }; + var check_iter = try stmt.iteratorAlloc(IdRow, allocator, .{}); + const row = try check_iter.nextAlloc(allocator, .{}); + try std.testing.expect(row != null); + defer allocator.free(row.?.id); + try std.testing.expectEqualSlices(u8, &zero_doc_id, row.?.id); + } +} + +// Feature: schema-aware-storage, Property 12: Schema version is persisted after migration +// For any successful migration, querying schema_meta immediately after +// Migration_Executor.execute returns SHALL yield a row whose version column equals +// the version string from the target Schema. +test "migration_executor: schema version persisted after migration" { + const allocator = std.testing.allocator; + + var prng = std.Random.DefaultPrng.init(55); + const rand = prng.random(); + + const table_names = [_][]const u8{ "things", "stuff", "items", "records", "entries" }; + const versions = [_][]const u8{ "1.0.0", "1.1.0", "1.2.3", "2.0.0", "0.1.0" }; + + var iter: usize = 0; + while (iter < 100) : (iter += 1) { + var db = try openMemDb(); + defer db.deinit(); + + var gen = ddl_generator.DDLGenerator.init(allocator); + const tname = table_names[rand.intRangeAtMost(usize, 0, table_names.len - 1)]; + const version = versions[rand.intRangeAtMost(usize, 0, versions.len - 1)]; + + // Build a create_table plan + var target_table = schema_helpers.makeSingleRuntimeTable(allocator, tname, &[_]schema_helpers.TestFieldDef{ + .{ .name = "name", .field_type = .text }, + }); + defer target_table.deinit(allocator); + const target_version = version; + + const changes = try allocator.alloc(migration_detector.Change, 1); + defer allocator.free(changes); + changes[0] = .{ + .kind = .create_table, + .table = &target_table, + .field = null, + }; + + const plan = migration_detector.MigrationPlan{ + .changes = changes, + .is_destructive = false, + }; + + var executor = MigrationExecutor.init(allocator, &db, &gen, .{ + .auto_migrate = .full, + .allow_destructive = false, + }); + + try executor.execute(plan, target_version); + + // Verify schema_meta has the correct version + var stmt = try db.prepare("SELECT version FROM schema_meta LIMIT 1"); + defer stmt.deinit(); + + const VersionRow = struct { version: []const u8 }; + var ver_iter = try stmt.iteratorAlloc(VersionRow, allocator, .{}); + const ver_row = try ver_iter.nextAlloc(allocator, .{}); + try std.testing.expect(ver_row != null); + defer allocator.free(ver_row.?.version); + try std.testing.expectEqualStrings(version, ver_row.?.version); + } +} + +// Feature: schema-aware-storage, Property 20: Major version bump is refused +// For any database whose persisted major version component is less than the major version +// component in the target Schema, Migration_Executor.execute SHALL return an error and +// SHALL NOT apply any changes. +test "migration_executor: major version bump is refused" { + const allocator = std.testing.allocator; + + var prng = std.Random.DefaultPrng.init(33); + const rand = prng.random(); + + const table_names = [_][]const u8{ "docs", "notes", "files", "blobs", "chunks" }; + + var iter: usize = 0; + while (iter < 100) : (iter += 1) { + var db = try openMemDb(); + defer db.deinit(); + + var gen = ddl_generator.DDLGenerator.init(allocator); + const tname = table_names[rand.intRangeAtMost(usize, 0, table_names.len - 1)]; + + // Create table and insert persisted version with lower major + var table = schema_helpers.makeSingleRuntimeTable(allocator, tname, &[_]schema_helpers.TestFieldDef{ + .{ .name = "data", .field_type = .text }, + }); + defer table.deinit(allocator); + const ddl = try gen.generateDDL(table); + defer allocator.free(ddl); + try execMultiSql(&db, allocator, ddl); + + // Persisted version: major = 1 + const persisted_major = rand.intRangeAtMost(u32, 1, 5); + const target_major = persisted_major + 1 + rand.intRangeAtMost(u32, 0, 3); + + const persisted_ver = try std.fmt.allocPrint(allocator, "{d}.0.0", .{persisted_major}); + defer allocator.free(persisted_ver); + const target_ver = try std.fmt.allocPrint(allocator, "{d}.0.0", .{target_major}); + defer allocator.free(target_ver); + + try insertSchemaMetaVersion(&db, allocator, persisted_ver); + + // Build a simple add_column plan + var target_table = schema_helpers.makeSingleRuntimeTable(allocator, tname, &[_]schema_helpers.TestFieldDef{ + .{ .name = "data", .field_type = .text }, + .{ .name = "extra", .field_type = .text }, + }); + defer target_table.deinit(allocator); + const target_version = target_ver; + + const changes = try allocator.alloc(migration_detector.Change, 1); + defer allocator.free(changes); + changes[0] = .{ + .kind = .add_column, + .table = &target_table, + .field = schema_helpers.makeField("extra", .text), + }; + + const plan = migration_detector.MigrationPlan{ + .changes = changes, + .is_destructive = false, + }; + + var executor = MigrationExecutor.init(allocator, &db, &gen, .{ + .auto_migrate = .full, + .allow_destructive = false, + }); + + const result = executor.execute(plan, target_version); + try std.testing.expectError(error.MajorVersionBumpNotAllowed, result); + + // Verify no changes were applied: column 'extra' should not exist + // Check via PRAGMA table_info - 'extra' column should not be present + const pragma_sql = try std.fmt.allocPrint( + allocator, + "PRAGMA table_info({s})", + .{tname}, + ); + defer allocator.free(pragma_sql); + + var pragma_stmt = try db.prepareDynamic(pragma_sql); + defer pragma_stmt.deinit(); + + const PragmaRow = struct { + cid: i64, + name: []const u8, + type: []const u8, + notnull: i64, + dflt_value: ?[]const u8, + pk: i64, + }; + + var found_extra = false; + var pragma_iter = try pragma_stmt.iteratorAlloc(PragmaRow, allocator, .{}); + while (try pragma_iter.nextAlloc(allocator, .{})) |row| { + defer { + allocator.free(row.name); + allocator.free(row.type); + if (row.dflt_value) |dv| allocator.free(dv); + } + if (std.mem.eql(u8, row.name, "extra")) { + found_extra = true; + } + } + try std.testing.expect(!found_extra); + } +} diff --git a/src/msgpack_utils_property_test.zig b/src/msgpack_utils_property_test.zig deleted file mode 100644 index e1a72711..00000000 --- a/src/msgpack_utils_property_test.zig +++ /dev/null @@ -1,352 +0,0 @@ -const std = @import("std"); - -const msgpack = @import("msgpack"); - -const msgpack_utils = @import("msgpack_utils.zig"); - -const testing = std.testing; - -// MessagePack utility properties -// -// Invariant: Oversized Payloads Rejected -// -// This test MUST FAIL on unfixed code (PackerIO uses DEFAULT_LIMITS which are too permissive). -// Failure confirms the bug exists. When the fix is applied (PackWithLimits + TIGHT_LIMITS), -// this test will PASS. -test "msgpack: reject oversized payloads (depth, array, map, string)" { - const allocator = testing.allocator; - - // --- Depth bomb --- - // Craft a MessagePack array nested 33 levels deep. - // Each level: fixarray of 1 element (0x91). Innermost: nil (0xc0). - // wire_limits.max_depth = 32, so 33 levels should trigger MaxDepthExceeded. - { - var depth_bomb: [34]u8 = undefined; - for (0..33) |i| { - depth_bomb[i] = 0x91; // fixarray of 1 element - } - depth_bomb[33] = 0xc0; // nil at innermost level - - var reader: std.Io.Reader = .fixed(&depth_bomb); - const result = msgpack_utils.decode(allocator, &reader); - if (result) |payload| { - payload.free(allocator); - // Bug confirmed: decode returned a valid Payload instead of error.MaxDepthExceeded - // Counterexample: depth bomb (20 levels) decoded successfully — PackerIO allows depth up to 1000 - return error.TestUnexpectedResult; - } else |err| { - try testing.expectEqual(error.MaxDepthExceeded, err); - } - } - - // --- Array bomb --- - // Craft a MessagePack array32 header claiming 100,001 elements, followed by 100,001 nil bytes. - // wire_limits.max_array_length = 100,000, so 100,001 elements should trigger ArrayTooLarge. - { - const count: u32 = 100001; - const array_bomb = try allocator.alloc(u8, 5 + 100001); - defer allocator.free(array_bomb); - array_bomb[0] = 0xdd; // array32 format byte - array_bomb[1] = @intCast((count >> 24) & 0xff); - array_bomb[2] = @intCast((count >> 16) & 0xff); - array_bomb[3] = @intCast((count >> 8) & 0xff); - array_bomb[4] = @intCast(count & 0xff); - for (5..5 + 100001) |i| { - array_bomb[i] = 0xc0; // nil - } - - var reader: std.Io.Reader = .fixed(array_bomb); - const result = msgpack_utils.decode(allocator, &reader); - if (result) |payload| { - payload.free(allocator); - // Bug confirmed: decode returned a valid Payload instead of error.ArrayTooLarge - // Counterexample: array bomb (2000 elements) decoded successfully — PackerIO allows up to 1,000,000 - return error.TestUnexpectedResult; - } else |err| { - try testing.expectEqual(error.ArrayTooLarge, err); - } - } - - // --- Map bomb --- - // Craft a MessagePack map32 header claiming 100,001 entries, followed by 200,002 nil bytes (key+value pairs). - // wire_limits.max_map_size = 100,000, so 100,001 entries should trigger MapTooLarge. - { - const count: u32 = 100001; - const map_bomb = try allocator.alloc(u8, 5 + 200002); - defer allocator.free(map_bomb); - map_bomb[0] = 0xdf; // map32 format byte - map_bomb[1] = @intCast((count >> 24) & 0xff); - map_bomb[2] = @intCast((count >> 16) & 0xff); - map_bomb[3] = @intCast((count >> 8) & 0xff); - map_bomb[4] = @intCast(count & 0xff); - for (5..5 + 200002) |i| { - map_bomb[i] = 0xc0; // nil (key and value both nil) - } - - var reader: std.Io.Reader = .fixed(map_bomb); - const result = msgpack_utils.decode(allocator, &reader); - if (result) |payload| { - payload.free(allocator); - // Bug confirmed: decode returned a valid Payload instead of error.MapTooLarge - // Counterexample: map bomb (2000 entries) decoded successfully — PackerIO allows up to 1,000,000 - return error.TestUnexpectedResult; - } else |err| { - try testing.expectEqual(error.MapTooLarge, err); - } - } - - // --- String bomb --- - // Craft a MessagePack str32 header claiming 2 MB, followed by 2 MB of zero bytes. - // wire_limits.max_string_length = 1 MB, so 2 MB should trigger StringTooLong. - { - const str_len: u32 = 2 * 1024 * 1024; - const total_size = 5 + str_len; - const string_bomb = try allocator.alloc(u8, total_size); - defer allocator.free(string_bomb); - - string_bomb[0] = 0xdb; // str32 format byte - string_bomb[1] = @intCast((str_len >> 24) & 0xff); - string_bomb[2] = @intCast((str_len >> 16) & 0xff); - string_bomb[3] = @intCast((str_len >> 8) & 0xff); - string_bomb[4] = @intCast(str_len & 0xff); - @memset(string_bomb[5..], 0); // zero bytes for string content - - var reader: std.Io.Reader = .fixed(string_bomb); - const result = msgpack_utils.decode(allocator, &reader); - if (result) |payload| { - payload.free(allocator); - // Bug confirmed: decode returned a valid Payload instead of error.StringTooLong - // Counterexample: string bomb (128 KB) decoded successfully — PackerIO allows up to 100 MB - return error.TestUnexpectedResult; - } else |err| { - try testing.expectEqual(error.StringTooLong, err); - } - } -} - -test "msgpack: round-trip encoding/decoding preservation" { - const allocator = testing.allocator; - var prng = std.Random.DefaultPrng.init(0xdeadbeef); - const random = prng.random(); - - // Test different types of payloads - for (0..100) |_| { - const payload_type = random.uintAtMost(u8, 4); - var payload: msgpack.Payload = undefined; - - switch (payload_type) { - 0 => { // Integer - payload = .{ .int = random.int(i64) }; - }, - 1 => { // Unsigned Integer - payload = .{ .uint = random.int(u64) }; - }, - 2 => { // Boolean - payload = .{ .bool = random.boolean() }; - }, - 3 => { // String - const len = random.uintAtMost(usize, 100); - const str = try allocator.alloc(u8, len); - defer allocator.free(str); - random.bytes(str); - // Ensure string is valid-ish (or just bytes for MsgPack purposes) - payload = try msgpack.Payload.strToPayload(str, allocator); - }, - 4 => { // Map (simple) - payload = msgpack.Payload.mapPayload(allocator); - const num_entries = random.uintAtMost(usize, 5); - for (0..num_entries) |i| { - const key_buf = try std.fmt.allocPrint(allocator, "key_{}", .{i}); - defer allocator.free(key_buf); - const val_buf = try std.fmt.allocPrint(allocator, "val_{}", .{i}); - defer allocator.free(val_buf); - - try payload.mapPut(key_buf, try msgpack.Payload.strToPayload(val_buf, allocator)); - } - }, - else => unreachable, - } - defer payload.free(allocator); - - // Encode using the project's standard ArrayList writer - var list: std.ArrayListUnmanaged(u8) = .empty; - defer list.deinit(allocator); - try msgpack_utils.encode(payload, list.writer(allocator)); - - // Get the encoded bytes - const encoded = try list.toOwnedSlice(allocator); - defer allocator.free(encoded); - - // Decode using the project's standard fixed reader - var reader: std.Io.Reader = .fixed(encoded); - const decoded = try msgpack_utils.decode(allocator, &reader); - defer decoded.free(allocator); - - // Verify equality - try verifyPayloadEquality(payload, decoded); - } -} - -// Boundary success properties -// -// Invariant: Boundary Success Tests -// -// Payloads exactly at the wire_limits boundary must decode successfully. -test "msgpack: boundary success (31 depth, 100000 items, 1MB str)" { - const allocator = testing.allocator; - - // --- Depth exactly 31 (max allowed with max_depth=32) --- - // The zig-msgpack depth check fires when parse_stack.items.len >= max_depth (32). - // So 31 levels of nesting is the maximum that succeeds. - { - var depth31: [32]u8 = undefined; - for (0..31) |i| { - depth31[i] = 0x91; // fixarray of 1 element - } - depth31[31] = 0xc0; // nil at innermost level - - var reader: std.Io.Reader = .fixed(&depth31); - const result = try msgpack_utils.decode(allocator, &reader); - result.free(allocator); - } - - // --- Array of exactly 100,000 elements --- - { - const count: u32 = 100000; - const total_size = 5 + count; - const array_exact = try allocator.alloc(u8, total_size); - defer allocator.free(array_exact); - - array_exact[0] = 0xdd; // array32 format byte - array_exact[1] = @intCast((count >> 24) & 0xff); - array_exact[2] = @intCast((count >> 16) & 0xff); - array_exact[3] = @intCast((count >> 8) & 0xff); - array_exact[4] = @intCast(count & 0xff); - @memset(array_exact[5..], 0xc0); // nil - - var reader: std.Io.Reader = .fixed(array_exact); - const result = try msgpack_utils.decode(allocator, &reader); - result.free(allocator); - } - - // --- String of exactly 1MB bytes --- - { - const str_len: u32 = 1 * 1024 * 1024; - const total_size = 5 + str_len; - const string_exact = try allocator.alloc(u8, total_size); - defer allocator.free(string_exact); - - string_exact[0] = 0xdb; // str32 format byte - string_exact[1] = @intCast((str_len >> 24) & 0xff); - string_exact[2] = @intCast((str_len >> 16) & 0xff); - string_exact[3] = @intCast((str_len >> 8) & 0xff); - string_exact[4] = @intCast(str_len & 0xff); - @memset(string_exact[5..], 0); - - var reader: std.Io.Reader = .fixed(string_exact); - const result = try msgpack_utils.decode(allocator, &reader); - result.free(allocator); - } -} - -// Boundary failure properties -// -// Invariant: One-Over-Boundary Tests -// -// Payloads one unit over the wire_limits boundary must return the appropriate limit error. -test "msgpack: reject one-over-boundary payloads" { - const allocator = testing.allocator; - - // --- Depth 32 (one over the effective max of 31 with max_depth=32) --- - { - var depth32: [33]u8 = undefined; - for (0..32) |i| { - depth32[i] = 0x91; // fixarray of 1 element - } - depth32[32] = 0xc0; // nil at innermost level - - var reader: std.Io.Reader = .fixed(&depth32); - const result = msgpack_utils.decode(allocator, &reader); - if (result) |payload| { - payload.free(allocator); - return error.TestUnexpectedResult; - } else |err| { - try testing.expectEqual(error.MaxDepthExceeded, err); - } - } - - // --- Array of exactly 100,001 elements (one over max_array_length=100,000) --- - { - const count: u32 = 100001; - const total_size = 5 + count; - const array_over = try allocator.alloc(u8, total_size); - defer allocator.free(array_over); - - array_over[0] = 0xdd; // array32 format byte - array_over[1] = @intCast((count >> 24) & 0xff); - array_over[2] = @intCast((count >> 16) & 0xff); - array_over[3] = @intCast((count >> 8) & 0xff); - array_over[4] = @intCast(count & 0xff); - @memset(array_over[5..], 0xc0); // nil - - var reader: std.Io.Reader = .fixed(array_over); - const result = msgpack_utils.decode(allocator, &reader); - if (result) |payload| { - payload.free(allocator); - return error.TestUnexpectedResult; - } else |err| { - try testing.expectEqual(error.ArrayTooLarge, err); - } - } - - // --- String of exactly 1MB+1 bytes (one over max_string_length=1MB) --- - { - const str_len: u32 = 1 * 1024 * 1024 + 1; - const total_size = 5 + str_len; - const string_over = try allocator.alloc(u8, total_size); - defer allocator.free(string_over); - - string_over[0] = 0xdb; // str32 format byte - string_over[1] = @intCast((str_len >> 24) & 0xff); - string_over[2] = @intCast((str_len >> 16) & 0xff); - string_over[3] = @intCast((str_len >> 8) & 0xff); - string_over[4] = @intCast(str_len & 0xff); - @memset(string_over[5..], 0); - - var reader: std.Io.Reader = .fixed(string_over); - const result = msgpack_utils.decode(allocator, &reader); - if (result) |payload| { - payload.free(allocator); - // Limit not enforced: string of 64KB+1 decoded successfully — unfixed code uses DEFAULT_LIMITS (max_string_length=100MB) - return error.TestUnexpectedResult; - } else |err| { - try testing.expectEqual(error.StringTooLong, err); - } - } -} - -fn verifyPayloadEquality(expected: msgpack.Payload, actual: msgpack.Payload) !void { - if (std.meta.activeTag(expected) != std.meta.activeTag(actual)) { - // Lenient integer check: both could be integers - if (expected.isInteger() and actual.isInteger()) { - const e_val = try expected.getUint(); - const a_val = try actual.getUint(); - try testing.expectEqual(e_val, a_val); - return; - } - std.debug.print("Tag mismatch: expected {}, found {}\n", .{ std.meta.activeTag(expected), std.meta.activeTag(actual) }); - try testing.expectEqual(std.meta.activeTag(expected), std.meta.activeTag(actual)); - } - - switch (expected) { - .int => |v| try testing.expectEqual(v, actual.int), - .uint => |v| try testing.expectEqual(v, actual.uint), - .bool => |v| try testing.expectEqual(v, actual.bool), - .str => |v| try testing.expectEqualStrings(v.value(), actual.str.value()), - .map => { - try testing.expectEqual(expected.map.count(), actual.map.count()); - // In a real test we'd iterate and compare entries - }, - else => {}, - } -} diff --git a/src/msgpack_utils_test.zig b/src/msgpack_utils_test.zig index df2ce5aa..32a1c272 100644 --- a/src/msgpack_utils_test.zig +++ b/src/msgpack_utils_test.zig @@ -1,5 +1,7 @@ const std = @import("std"); +const msgpack = @import("msgpack"); + const msgpack_utils = @import("msgpack_utils.zig"); const testing = std.testing; @@ -68,3 +70,348 @@ test "msgpack_utils: writeMsgPackStr str32 (>65535 bytes)" { try testing.expectEqual(@as(u8, 0xdb), buf.items[0]); try testing.expectEqual(@as(u32, len), std.mem.readInt(u32, buf.items[1..5], .big)); } + +// MessagePack utility properties +// +// Invariant: Oversized Payloads Rejected +// +// This test MUST FAIL on unfixed code (PackerIO uses DEFAULT_LIMITS which are too permissive). +// Failure confirms the bug exists. When the fix is applied (PackWithLimits + TIGHT_LIMITS), +// this test will PASS. +test "msgpack: reject oversized payloads (depth, array, map, string)" { + const allocator = testing.allocator; + + // --- Depth bomb --- + // Craft a MessagePack array nested 33 levels deep. + // Each level: fixarray of 1 element (0x91). Innermost: nil (0xc0). + // wire_limits.max_depth = 32, so 33 levels should trigger MaxDepthExceeded. + { + var depth_bomb: [34]u8 = undefined; + for (0..33) |i| { + depth_bomb[i] = 0x91; // fixarray of 1 element + } + depth_bomb[33] = 0xc0; // nil at innermost level + + var reader: std.Io.Reader = .fixed(&depth_bomb); + const result = msgpack_utils.decode(allocator, &reader); + if (result) |payload| { + payload.free(allocator); + // Bug confirmed: decode returned a valid Payload instead of error.MaxDepthExceeded + // Counterexample: depth bomb (20 levels) decoded successfully — PackerIO allows depth up to 1000 + return error.TestUnexpectedResult; + } else |err| { + try testing.expectEqual(error.MaxDepthExceeded, err); + } + } + + // --- Array bomb --- + // Craft a MessagePack array32 header claiming 100,001 elements, followed by 100,001 nil bytes. + // wire_limits.max_array_length = 100,000, so 100,001 elements should trigger ArrayTooLarge. + { + const count: u32 = 100001; + const array_bomb = try allocator.alloc(u8, 5 + 100001); + defer allocator.free(array_bomb); + array_bomb[0] = 0xdd; // array32 format byte + array_bomb[1] = @intCast((count >> 24) & 0xff); + array_bomb[2] = @intCast((count >> 16) & 0xff); + array_bomb[3] = @intCast((count >> 8) & 0xff); + array_bomb[4] = @intCast(count & 0xff); + for (5..5 + 100001) |i| { + array_bomb[i] = 0xc0; // nil + } + + var reader: std.Io.Reader = .fixed(array_bomb); + const result = msgpack_utils.decode(allocator, &reader); + if (result) |payload| { + payload.free(allocator); + // Bug confirmed: decode returned a valid Payload instead of error.ArrayTooLarge + // Counterexample: array bomb (2000 elements) decoded successfully — PackerIO allows up to 1,000,000 + return error.TestUnexpectedResult; + } else |err| { + try testing.expectEqual(error.ArrayTooLarge, err); + } + } + + // --- Map bomb --- + // Craft a MessagePack map32 header claiming 100,001 entries, followed by 200,002 nil bytes (key+value pairs). + // wire_limits.max_map_size = 100,000, so 100,001 entries should trigger MapTooLarge. + { + const count: u32 = 100001; + const map_bomb = try allocator.alloc(u8, 5 + 200002); + defer allocator.free(map_bomb); + map_bomb[0] = 0xdf; // map32 format byte + map_bomb[1] = @intCast((count >> 24) & 0xff); + map_bomb[2] = @intCast((count >> 16) & 0xff); + map_bomb[3] = @intCast((count >> 8) & 0xff); + map_bomb[4] = @intCast(count & 0xff); + for (5..5 + 200002) |i| { + map_bomb[i] = 0xc0; // nil (key and value both nil) + } + + var reader: std.Io.Reader = .fixed(map_bomb); + const result = msgpack_utils.decode(allocator, &reader); + if (result) |payload| { + payload.free(allocator); + // Bug confirmed: decode returned a valid Payload instead of error.MapTooLarge + // Counterexample: map bomb (2000 entries) decoded successfully — PackerIO allows up to 1,000,000 + return error.TestUnexpectedResult; + } else |err| { + try testing.expectEqual(error.MapTooLarge, err); + } + } + + // --- String bomb --- + // Craft a MessagePack str32 header claiming 2 MB, followed by 2 MB of zero bytes. + // wire_limits.max_string_length = 1 MB, so 2 MB should trigger StringTooLong. + { + const str_len: u32 = 2 * 1024 * 1024; + const total_size = 5 + str_len; + const string_bomb = try allocator.alloc(u8, total_size); + defer allocator.free(string_bomb); + + string_bomb[0] = 0xdb; // str32 format byte + string_bomb[1] = @intCast((str_len >> 24) & 0xff); + string_bomb[2] = @intCast((str_len >> 16) & 0xff); + string_bomb[3] = @intCast((str_len >> 8) & 0xff); + string_bomb[4] = @intCast(str_len & 0xff); + @memset(string_bomb[5..], 0); // zero bytes for string content + + var reader: std.Io.Reader = .fixed(string_bomb); + const result = msgpack_utils.decode(allocator, &reader); + if (result) |payload| { + payload.free(allocator); + // Bug confirmed: decode returned a valid Payload instead of error.StringTooLong + // Counterexample: string bomb (128 KB) decoded successfully — PackerIO allows up to 100 MB + return error.TestUnexpectedResult; + } else |err| { + try testing.expectEqual(error.StringTooLong, err); + } + } +} + +test "msgpack: round-trip encoding/decoding preservation" { + const allocator = testing.allocator; + var prng = std.Random.DefaultPrng.init(0xdeadbeef); + const random = prng.random(); + + // Test different types of payloads + for (0..100) |_| { + const payload_type = random.uintAtMost(u8, 4); + var payload: msgpack.Payload = undefined; + + switch (payload_type) { + 0 => { // Integer + payload = .{ .int = random.int(i64) }; + }, + 1 => { // Unsigned Integer + payload = .{ .uint = random.int(u64) }; + }, + 2 => { // Boolean + payload = .{ .bool = random.boolean() }; + }, + 3 => { // String + const len = random.uintAtMost(usize, 100); + const str = try allocator.alloc(u8, len); + defer allocator.free(str); + random.bytes(str); + // Ensure string is valid-ish (or just bytes for MsgPack purposes) + payload = try msgpack.Payload.strToPayload(str, allocator); + }, + 4 => { // Map (simple) + payload = msgpack.Payload.mapPayload(allocator); + const num_entries = random.uintAtMost(usize, 5); + for (0..num_entries) |i| { + const key_buf = try std.fmt.allocPrint(allocator, "key_{}", .{i}); + defer allocator.free(key_buf); + const val_buf = try std.fmt.allocPrint(allocator, "val_{}", .{i}); + defer allocator.free(val_buf); + + try payload.mapPut(key_buf, try msgpack.Payload.strToPayload(val_buf, allocator)); + } + }, + else => unreachable, + } + defer payload.free(allocator); + + // Encode using the project's standard ArrayList writer + var list: std.ArrayListUnmanaged(u8) = .empty; + defer list.deinit(allocator); + try msgpack_utils.encode(payload, list.writer(allocator)); + + // Get the encoded bytes + const encoded = try list.toOwnedSlice(allocator); + defer allocator.free(encoded); + + // Decode using the project's standard fixed reader + var reader: std.Io.Reader = .fixed(encoded); + const decoded = try msgpack_utils.decode(allocator, &reader); + defer decoded.free(allocator); + + // Verify equality + try verifyPayloadEquality(payload, decoded); + } +} + +// Boundary success properties +// +// Invariant: Boundary Success Tests +// +// Payloads exactly at the wire_limits boundary must decode successfully. +test "msgpack: boundary success (31 depth, 100000 items, 1MB str)" { + const allocator = testing.allocator; + + // --- Depth exactly 31 (max allowed with max_depth=32) --- + // The zig-msgpack depth check fires when parse_stack.items.len >= max_depth (32). + // So 31 levels of nesting is the maximum that succeeds. + { + var depth31: [32]u8 = undefined; + for (0..31) |i| { + depth31[i] = 0x91; // fixarray of 1 element + } + depth31[31] = 0xc0; // nil at innermost level + + var reader: std.Io.Reader = .fixed(&depth31); + const result = try msgpack_utils.decode(allocator, &reader); + result.free(allocator); + } + + // --- Array of exactly 100,000 elements --- + { + const count: u32 = 100000; + const total_size = 5 + count; + const array_exact = try allocator.alloc(u8, total_size); + defer allocator.free(array_exact); + + array_exact[0] = 0xdd; // array32 format byte + array_exact[1] = @intCast((count >> 24) & 0xff); + array_exact[2] = @intCast((count >> 16) & 0xff); + array_exact[3] = @intCast((count >> 8) & 0xff); + array_exact[4] = @intCast(count & 0xff); + @memset(array_exact[5..], 0xc0); // nil + + var reader: std.Io.Reader = .fixed(array_exact); + const result = try msgpack_utils.decode(allocator, &reader); + result.free(allocator); + } + + // --- String of exactly 1MB bytes --- + { + const str_len: u32 = 1 * 1024 * 1024; + const total_size = 5 + str_len; + const string_exact = try allocator.alloc(u8, total_size); + defer allocator.free(string_exact); + + string_exact[0] = 0xdb; // str32 format byte + string_exact[1] = @intCast((str_len >> 24) & 0xff); + string_exact[2] = @intCast((str_len >> 16) & 0xff); + string_exact[3] = @intCast((str_len >> 8) & 0xff); + string_exact[4] = @intCast(str_len & 0xff); + @memset(string_exact[5..], 0); + + var reader: std.Io.Reader = .fixed(string_exact); + const result = try msgpack_utils.decode(allocator, &reader); + result.free(allocator); + } +} + +// Boundary failure properties +// +// Invariant: One-Over-Boundary Tests +// +// Payloads one unit over the wire_limits boundary must return the appropriate limit error. +test "msgpack: reject one-over-boundary payloads" { + const allocator = testing.allocator; + + // --- Depth 32 (one over the effective max of 31 with max_depth=32) --- + { + var depth32: [33]u8 = undefined; + for (0..32) |i| { + depth32[i] = 0x91; // fixarray of 1 element + } + depth32[32] = 0xc0; // nil at innermost level + + var reader: std.Io.Reader = .fixed(&depth32); + const result = msgpack_utils.decode(allocator, &reader); + if (result) |payload| { + payload.free(allocator); + return error.TestUnexpectedResult; + } else |err| { + try testing.expectEqual(error.MaxDepthExceeded, err); + } + } + + // --- Array of exactly 100,001 elements (one over max_array_length=100,000) --- + { + const count: u32 = 100001; + const total_size = 5 + count; + const array_over = try allocator.alloc(u8, total_size); + defer allocator.free(array_over); + + array_over[0] = 0xdd; // array32 format byte + array_over[1] = @intCast((count >> 24) & 0xff); + array_over[2] = @intCast((count >> 16) & 0xff); + array_over[3] = @intCast((count >> 8) & 0xff); + array_over[4] = @intCast(count & 0xff); + @memset(array_over[5..], 0xc0); // nil + + var reader: std.Io.Reader = .fixed(array_over); + const result = msgpack_utils.decode(allocator, &reader); + if (result) |payload| { + payload.free(allocator); + return error.TestUnexpectedResult; + } else |err| { + try testing.expectEqual(error.ArrayTooLarge, err); + } + } + + // --- String of exactly 1MB+1 bytes (one over max_string_length=1MB) --- + { + const str_len: u32 = 1 * 1024 * 1024 + 1; + const total_size = 5 + str_len; + const string_over = try allocator.alloc(u8, total_size); + defer allocator.free(string_over); + + string_over[0] = 0xdb; // str32 format byte + string_over[1] = @intCast((str_len >> 24) & 0xff); + string_over[2] = @intCast((str_len >> 16) & 0xff); + string_over[3] = @intCast((str_len >> 8) & 0xff); + string_over[4] = @intCast(str_len & 0xff); + @memset(string_over[5..], 0); + + var reader: std.Io.Reader = .fixed(string_over); + const result = msgpack_utils.decode(allocator, &reader); + if (result) |payload| { + payload.free(allocator); + // Limit not enforced: string of 64KB+1 decoded successfully — unfixed code uses DEFAULT_LIMITS (max_string_length=100MB) + return error.TestUnexpectedResult; + } else |err| { + try testing.expectEqual(error.StringTooLong, err); + } + } +} + +fn verifyPayloadEquality(expected: msgpack.Payload, actual: msgpack.Payload) !void { + if (std.meta.activeTag(expected) != std.meta.activeTag(actual)) { + // Lenient integer check: both could be integers + if (expected.isInteger() and actual.isInteger()) { + const e_val = try expected.getUint(); + const a_val = try actual.getUint(); + try testing.expectEqual(e_val, a_val); + return; + } + std.debug.print("Tag mismatch: expected {}, found {}\n", .{ std.meta.activeTag(expected), std.meta.activeTag(actual) }); + try testing.expectEqual(std.meta.activeTag(expected), std.meta.activeTag(actual)); + } + + switch (expected) { + .int => |v| try testing.expectEqual(v, actual.int), + .uint => |v| try testing.expectEqual(v, actual.uint), + .bool => |v| try testing.expectEqual(v, actual.bool), + .str => |v| try testing.expectEqualStrings(v.value(), actual.str.value()), + .map => { + try testing.expectEqual(expected.map.count(), actual.map.count()); + // In a real test we'd iterate and compare entries + }, + else => {}, + } +} diff --git a/src/server_init_property_test.zig b/src/server_init_test.zig similarity index 100% rename from src/server_init_property_test.zig rename to src/server_init_test.zig diff --git a/src/storage_engine_error_property_test.zig b/src/storage_engine_error_test.zig similarity index 100% rename from src/storage_engine_error_property_test.zig rename to src/storage_engine_error_test.zig diff --git a/src/storage_engine_property_test.zig b/src/storage_engine_property_test.zig deleted file mode 100644 index a8bef5f4..00000000 --- a/src/storage_engine_property_test.zig +++ /dev/null @@ -1,472 +0,0 @@ -const std = @import("std"); - -const qth = @import("query/test_helpers.zig"); -const schema_helpers = @import("schema/test_helpers.zig"); -const sth = @import("storage_engine_test_helpers.zig"); -const tth = @import("typed/test_helpers.zig"); - -const testing = std.testing; -const StorageEngine = sth.StorageEngine; - -test "storage: engine initialization errors" { - const allocator = testing.allocator; - var ms: sth.MemoryStrategy = undefined; - try ms.init(allocator); - defer std.debug.assert(ms.deinit() == .ok); - - // Test 1: Invalid directory path - const invalid_dir = ""; - var sm1 = try sth.createDummySchema(allocator); - defer sm1.deinit(); - var engine1: StorageEngine = undefined; - const result1 = engine1.init(allocator, &ms, invalid_dir, &sm1, .{}, .{ .in_memory = false }, null, null); - try testing.expectError(error.InvalidDataDir, result1); - - // Test 2: Path that is a file, not a directory - var context_not_dir = try sth.TestContext.init(allocator, "storage-not-dir"); - defer context_not_dir.deinit(); - const test_file = try std.fs.path.join(allocator, &.{ context_not_dir.test_dir, "test_file_not_dir.txt" }); - defer allocator.free(test_file); - - // Create a file - const file = try std.fs.cwd().createFile(test_file, .{}); - file.close(); - - var sm2 = try sth.createDummySchema(allocator); - defer sm2.deinit(); - var engine2: StorageEngine = undefined; - const result2 = engine2.init(allocator, &ms, test_file, &sm2, .{}, .{ .in_memory = false }, null, null); - try testing.expectError(error.NotDir, result2); - - // Test 3: Valid initialization should succeed - var context_valid = try sth.TestContext.init(allocator, "storage-init-valid"); - defer context_valid.deinit(); - const test_dir = context_valid.test_dir; - - var sm3 = try sth.createDummySchema(allocator); - defer sm3.deinit(); - var engine3: StorageEngine = undefined; - try engine3.init(allocator, &ms, test_dir, &sm3, .{}, .{ .in_memory = false, .reader_pool_size = 1 }, null, null); - defer engine3.deinit(); - - // Verify database file was created - const db_path = try std.fs.path.join(allocator, &.{ test_dir, "zyncbase.db" }); - defer allocator.free(db_path); - const db_file = try std.fs.cwd().openFile(db_path, .{}); - db_file.close(); -} - -// Storage engine thread safety properties -test "storage: thread-safe engine access" { - const allocator = testing.allocator; - var fields_arr = [_]sth.Field{schema_helpers.makeField("val", .text)}; - const table = schema_helpers.makeTable("test", &fields_arr); - var ctx: sth.EngineTestContext = undefined; - try sth.setupEngine(&ctx, allocator, "prop-multi-table", table); - defer ctx.deinit(); - const engine = &ctx.engine; - - // Concurrent writes and reads - const num_threads = 10; - const ops_per_thread = 50; - const ThreadContext = struct { - ctx: *sth.EngineTestContext, - }; - const WriteThread = struct { - fn run(t_ctx: ThreadContext, thread_id: usize) !void { - var i: usize = 0; - while (i < ops_per_thread) : (i += 1) { - const key: u128 = thread_id * 1_000 + i + 1; - const value = try std.fmt.allocPrint( - testing.allocator, - "{{\"thread\":{d},\"op\":{d}}}", - .{ thread_id, i }, - ); - defer testing.allocator.free(value); - try t_ctx.ctx.insertField("test", key, 1, "val", tth.valText(value)); - } - } - }; - const ReadThread = struct { - fn run(eng: *StorageEngine, table_index: usize, thread_id: usize) !void { - var i: usize = 0; - while (i < ops_per_thread) : (i += 1) { - const key: u128 = (thread_id % (num_threads / 2)) * 1_000 + i + 1; - const record = try sth.readDoc(testing.allocator, eng, table_index, key, 1); - defer if (record) |r| r.deinit(testing.allocator); - } - } - }; - - // Spawn write threads - var write_threads: [num_threads / 2]std.Thread = undefined; - for (&write_threads, 0..) |*thread, i| { - thread.* = try std.Thread.spawn(.{}, WriteThread.run, .{ ThreadContext{ .ctx = &ctx }, i }); - } - // Spawn read threads - var read_threads: [num_threads / 2]std.Thread = undefined; - const test_table = try ctx.table("test"); - for (&read_threads, 0..) |*thread, i| { - thread.* = try std.Thread.spawn(.{}, ReadThread.run, .{ engine, test_table.metadata.index, i }); - } - // Wait for all threads - for (write_threads) |thread| { - thread.join(); - } - for (read_threads) |thread| { - thread.join(); - } - // Flush writes and verify data - try engine.flushPendingWrites(); - // Verify some data was written - const record = try test_table.readDoc(allocator, 1, 1); - defer if (record) |r| r.deinit(allocator); - try testing.expect(record != null); -} - -test "storage: connection pool reuse and release" { - const allocator = testing.allocator; - var fields_arr = [_]sth.Field{schema_helpers.makeField("val", .text)}; - const table = schema_helpers.makeTable("test", &fields_arr); - var ctx: sth.EngineTestContext = undefined; - try sth.setupEngine(&ctx, allocator, "prop-many-ns", table); - defer ctx.deinit(); - const engine = &ctx.engine; - - // Set some initial data - { - try ctx.insertText("test", 1, 1, "val", "test1"); - try ctx.insertText("test", 2, 1, "val", "test2"); - } - try engine.flushPendingWrites(); - // Perform many read operations to ensure connections are being reused - // If connections weren't released, we'd run out of connections - const num_operations = 1000; - var i: usize = 0; - const test_table = try ctx.table("test"); - while (i < num_operations) : (i += 1) { - const key: u128 = if (i % 2 == 0) 1 else 2; - const record = try test_table.readDoc(testing.allocator, key, 1); - defer if (record) |r| r.deinit(allocator); - try testing.expect(record != null); - } - // If we got here, connections were properly released and reused -} - -test "storage: persistence round-trip (various types)" { - const allocator = testing.allocator; - var fields_arr = [_]sth.Field{schema_helpers.makeField("val", .text)}; - const table = schema_helpers.makeTable("test", &fields_arr); - var ctx: sth.EngineTestContext = undefined; - try sth.setupEngine(&ctx, allocator, "prop-burst", table); - defer ctx.deinit(); - const engine = &ctx.engine; - - // Test various data types and values - const test_cases = [_]struct { - namespace_id: i64, - id: u128, - value: []const u8, - }{ - .{ .namespace_id = 1, .id = 1, .value = "{\"data\":\"simple\"}" }, - .{ .namespace_id = 1, .id = 2, .value = "{\"user\":{\"name\":\"Alice\",\"age\":30}}" }, - .{ .namespace_id = 2, .id = 3, .value = "[1,2,3,4,5]" }, - .{ .namespace_id = 2, .id = 4, .value = "{}" }, - .{ .namespace_id = 3, .id = 5, .value = "{\"text\":\"Hello 世界 🌍\"}" }, - .{ .namespace_id = 3, .id = 6, .value = "{\"chars\":\"\\\"\\n\\t\\r\"}" }, - }; - // Insert all test cases - for (test_cases) |tc| { - try ctx.insertText("test", tc.id, tc.namespace_id, "val", tc.value); - } - // Flush writes - try engine.flushPendingWrites(); - const test_table = try ctx.table("test"); - // Retrieve and verify all test cases - for (test_cases) |tc| { - const record = try test_table.readDoc(allocator, tc.id, tc.namespace_id); - defer if (record) |r| r.deinit(allocator); - try testing.expect(record != null); - } -} - -test "storage: insert/delete inverse consistency" { - const allocator = testing.allocator; - var fields_arr = [_]sth.Field{schema_helpers.makeField("val", .text)}; - const table = schema_helpers.makeTable("test", &fields_arr); - var ctx: sth.EngineTestContext = undefined; - try sth.setupEngine(&ctx, allocator, "storage-inverse", table); - defer ctx.deinit(); - const engine = &ctx.engine; - - const test_cases = [_]struct { - namespace_id: i64, - id: u128, - value: []const u8, - }{ - .{ .namespace_id = 1, .id = 1, .value = "{\"data\":\"value1\"}" }, - .{ .namespace_id = 1, .id = 2, .value = "{\"data\":\"value2\"}" }, - .{ .namespace_id = 2, .id = 3, .value = "{\"data\":\"value3\"}" }, - }; - const test_table = try ctx.table("test"); - for (test_cases) |tc| { - // Insert - try ctx.insertText("test", tc.id, tc.namespace_id, "val", tc.value); - try engine.flushPendingWrites(); - // Verify it exists - const record1 = try test_table.readDoc(allocator, tc.id, tc.namespace_id); - defer if (record1) |r| r.deinit(allocator); - try testing.expect(record1 != null); - - // Delete - try engine.enqueueWriteOp(.{ .delete = .{ .table_index = test_table.metadata.index, .id = tc.id, .namespace_id = tc.namespace_id } }); - try engine.flushPendingWrites(); - // Verify it's gone - const record2 = try test_table.readDoc(allocator, tc.id, tc.namespace_id); - defer if (record2) |r| r.deinit(allocator); - try testing.expect(record2 == null); - } -} - -test "storage: transaction isolation and consistency" { - const allocator = testing.allocator; - var fields_arr = [_]sth.Field{schema_helpers.makeField("val", .text)}; - const table = schema_helpers.makeTable("test", &fields_arr); - var ctx: sth.EngineTestContext = undefined; - try sth.setupEngine(&ctx, allocator, "storage-txn-isolation", table); - defer ctx.deinit(); - const engine = &ctx.engine; - - // The write thread uses transactions internally to ensure atomicity - // Set up initial state - { - try ctx.insertText("test", 1, 1, "val", "initial1"); - try ctx.insertText("test", 2, 1, "val", "initial2"); - } - try engine.flushPendingWrites(); - const test_table = try ctx.table("test"); - // Verify initial state - const record1 = try test_table.readDoc(allocator, 1, 1); - defer if (record1) |r| r.deinit(allocator); - try testing.expect(record1 != null); - const record2 = try test_table.readDoc(allocator, 2, 1); - defer if (record2) |r| r.deinit(allocator); - try testing.expect(record2 != null); - // Queue multiple operations that should execute atomically in a batch - { - try ctx.insertText("test", 1, 1, "val", "updated1"); - try ctx.insertText("test", 2, 1, "val", "updated2"); - try ctx.insertText("test", 3, 1, "val", "new3"); - } - // Flush to ensure operations are processed - try engine.flushPendingWrites(); - - const record_up1 = try test_table.readDoc(allocator, 1, 1); - defer if (record_up1) |r| r.deinit(allocator); - - const record_up2 = try test_table.readDoc(allocator, 2, 1); - defer if (record_up2) |r| r.deinit(allocator); - - const record_n3 = try test_table.readDoc(allocator, 3, 1); - defer if (record_n3) |r| r.deinit(allocator); - - try testing.expect(record_up1 != null); - try testing.expect(record_up2 != null); - try testing.expect(record_n3 != null); -} - -test "storage: concurrent batch processing" { - const allocator = testing.allocator; - var fields_arr = [_]sth.Field{schema_helpers.makeField("val", .text)}; - const table = schema_helpers.makeTable("test", &fields_arr); - var ctx: sth.EngineTestContext = undefined; - try sth.setupEngine(&ctx, allocator, "storage-concurrent-batches", table); - defer ctx.deinit(); - const engine = &ctx.engine; - - // Property: Multiple batches can be queued and will be processed sequentially and atomically - const num_batches = 5; - const ops_per_batch = 20; - var j: usize = 0; - while (j < num_batches) : (j += 1) { - var i: usize = 0; - while (i < ops_per_batch) : (i += 1) { - const key: u128 = j * 100 + i + 1; - const value = try std.fmt.allocPrint(allocator, "{{\"batch\":{d},\"op\":{d}}}", .{ j, i }); - defer allocator.free(value); - try ctx.insertText("test", key, 1, "val", value); - } - } - // Flush and verify all operations succeeded atomically - try engine.flushPendingWrites(); - const test_table = try ctx.table("test"); - var total_found: usize = 0; - var k: usize = 0; - while (k < num_batches) : (k += 1) { - var i: usize = 0; - while (i < ops_per_batch) : (i += 1) { - const key: u128 = k * 100 + i + 1; - const record = try test_table.readDoc(allocator, key, 1); - defer if (record) |r| r.deinit(allocator); - if (record != null) total_found += 1; - } - } - try testing.expectEqual(@as(usize, num_batches * ops_per_batch), total_found); -} - -// Property: Database remains consistent after repeated flush operations -test "storage: repeated flush consistency" { - const allocator = testing.allocator; - var fields_arr = [_]sth.Field{schema_helpers.makeField("val", .text)}; - const table = schema_helpers.makeTable("test", &fields_arr); - var ctx: sth.EngineTestContext = undefined; - try sth.setupEngine(&ctx, allocator, "storage-repeated-flush", table); - defer ctx.deinit(); - const engine = &ctx.engine; - - var i: usize = 0; - while (i < 50) : (i += 1) { - try ctx.insertText("test", i + 1, 1, "val", "value"); - if (i % 5 == 0) { - try engine.flushPendingWrites(); - } - } - try engine.flushPendingWrites(); - const test_table = try ctx.table("test"); - var filter = try qth.makeDefaultFilter(allocator); - defer filter.deinit(allocator); - const qres = try test_table.queryDocs(allocator, 1, &filter); - defer qres.deinit(allocator); - try testing.expectEqual(@as(usize, 50), qres.records.len); -} - -// Additional property: Data integrity across engine restarts -test "storage: data persistence across restarts" { - const allocator = testing.allocator; - var context = try sth.TestContext.init(allocator, "storage-restart-persistence"); - defer context.deinit(); - const test_dir = context.test_dir; - - var fields_arr = [_]sth.Field{schema_helpers.makeField("val", .text)}; - const table = schema_helpers.makeTable("test", &fields_arr); - - // Initial run: Insert data - { - var ctx: sth.EngineTestContext = undefined; - try sth.setupEngineWithDir(&ctx, allocator, test_dir, table, .{ .in_memory = false }); - // Use deinitNoCleanup to preserve the test directory for the second run - defer ctx.deinitNoCleanup(); - - try ctx.insertText("test", 1, 1, "val", "persistent-value"); - try ctx.engine.flushPendingWrites(); - } - - // Second run: Verify data is still there - { - var ctx: sth.EngineTestContext = undefined; - try sth.setupEngineWithDir(&ctx, allocator, test_dir, table, .{ .in_memory = false }); - defer ctx.deinit(); - - const test_table = try ctx.table("test"); - const record = try test_table.readDoc(allocator, 1, 1); - defer if (record) |r| r.deinit(allocator); - try testing.expect(record != null); - } -} - -// Property: Schema updates are reflected in persistence logic -test "storage: schema update integrity" { - const allocator = testing.allocator; - var context = try sth.TestContext.init(allocator, "storage-schema-update"); - defer context.deinit(); - const test_dir = context.test_dir; - - var fields_v1 = [_]sth.Field{schema_helpers.makeField("val1", .text)}; - const table_v1 = schema_helpers.makeTable("test", &fields_v1); - - // Run 1: Version 1 schema - insert data - { - var ctx: sth.EngineTestContext = undefined; - try sth.setupEngineWithDir(&ctx, allocator, test_dir, table_v1, .{ .in_memory = false }); - defer ctx.deinitNoCleanup(); - - try ctx.insertText("test", 1, 1, "val1", "value1"); - try ctx.engine.flushPendingWrites(); - } - - // Run 2: Version 2 schema (added field) - verify existing data is still readable - { - var fields_v2 = [_]sth.Field{ - schema_helpers.makeField("val1", .text), - schema_helpers.makeField("val2", .integer), - }; - const table_v2 = schema_helpers.makeTable("test", &fields_v2); - - var ctx: sth.EngineTestContext = undefined; - try sth.setupEngineWithDir(&ctx, allocator, test_dir, table_v2, .{ .in_memory = false }); - defer ctx.deinit(); - - const test_table = try ctx.table("test"); - - // Existing data should be accessible - const record1 = try test_table.readDoc(allocator, 1, 1); - defer if (record1) |r| r.deinit(allocator); - try testing.expect(record1 != null); - - // New data with new field - try ctx.insertInt("test", 2, 1, "val2", 42); - try ctx.engine.flushPendingWrites(); - - const record2 = try test_table.readDoc(allocator, 2, 1); - defer if (record2) |r| r.deinit(allocator); - try testing.expect(record2 != null); - } -} - -// Fuzzy testing of random operations to ensure no crashes -test "storage: random operations fuzzing" { - const allocator = testing.allocator; - var fields_arr = [_]sth.Field{ - schema_helpers.makeField("title", .text), - schema_helpers.makeField("score", .integer), - }; - const table = schema_helpers.makeTable("items", &fields_arr); - var ctx: sth.EngineTestContext = undefined; - try sth.setupEngine(&ctx, allocator, "storage-fuzz", table); - defer ctx.deinit(); - const engine = &ctx.engine; - - var prng = std.Random.DefaultPrng.init(0); - const rand = prng.random(); - - var i: usize = 0; - while (i < 200) : (i += 1) { - const op = rand.intRangeAtMost(u8, 0, 3); - const id: u128 = rand.intRangeAtMost(u128, 1, 50); - const ns: i64 = @intCast(rand.intRangeAtMost(i32, 1, 5)); - - switch (op) { - 0 => { - // Insert/Update - try ctx.insertText("items", id, ns, "title", "fuzzy"); - try ctx.insertInt("items", id, ns, "score", @intCast(i)); - }, - 1 => { - // Delete - try engine.enqueueWriteOp(.{ .delete = .{ .table_index = 0, .id = id, .namespace_id = ns } }); - }, - 2 => { - // Query - const test_table = try ctx.table("items"); - const record = try test_table.readDoc(allocator, id, ns); - if (record) |r| r.deinit(allocator); - }, - 3 => { - // Flush - try engine.flushPendingWrites(); - }, - else => unreachable, - } - } - try engine.flushPendingWrites(); -} diff --git a/src/storage_engine_stability_property_test.zig b/src/storage_engine_stability_test.zig similarity index 100% rename from src/storage_engine_stability_property_test.zig rename to src/storage_engine_stability_test.zig diff --git a/src/storage_engine_test.zig b/src/storage_engine_test.zig index 82023b24..00e76929 100644 --- a/src/storage_engine_test.zig +++ b/src/storage_engine_test.zig @@ -1099,3 +1099,467 @@ test "StorageEngine: failed document cache update evicts stale entry post-commit try testing.expect(record != null); try testing.expectEqualStrings("new_val", record.?.values[val_field_idx].scalar.text); } + +const StorageEngine = sth.StorageEngine; +test "storage: engine initialization errors" { + const allocator = testing.allocator; + var ms: sth.MemoryStrategy = undefined; + try ms.init(allocator); + defer std.debug.assert(ms.deinit() == .ok); + + // Test 1: Invalid directory path + const invalid_dir = ""; + var sm1 = try sth.createDummySchema(allocator); + defer sm1.deinit(); + var engine1: StorageEngine = undefined; + const result1 = engine1.init(allocator, &ms, invalid_dir, &sm1, .{}, .{ .in_memory = false }, null, null); + try testing.expectError(error.InvalidDataDir, result1); + + // Test 2: Path that is a file, not a directory + var context_not_dir = try sth.TestContext.init(allocator, "storage-not-dir"); + defer context_not_dir.deinit(); + const test_file = try std.fs.path.join(allocator, &.{ context_not_dir.test_dir, "test_file_not_dir.txt" }); + defer allocator.free(test_file); + + // Create a file + const file = try std.fs.cwd().createFile(test_file, .{}); + file.close(); + + var sm2 = try sth.createDummySchema(allocator); + defer sm2.deinit(); + var engine2: StorageEngine = undefined; + const result2 = engine2.init(allocator, &ms, test_file, &sm2, .{}, .{ .in_memory = false }, null, null); + try testing.expectError(error.NotDir, result2); + + // Test 3: Valid initialization should succeed + var context_valid = try sth.TestContext.init(allocator, "storage-init-valid"); + defer context_valid.deinit(); + const test_dir = context_valid.test_dir; + + var sm3 = try sth.createDummySchema(allocator); + defer sm3.deinit(); + var engine3: StorageEngine = undefined; + try engine3.init(allocator, &ms, test_dir, &sm3, .{}, .{ .in_memory = false, .reader_pool_size = 1 }, null, null); + defer engine3.deinit(); + + // Verify database file was created + const db_path = try std.fs.path.join(allocator, &.{ test_dir, "zyncbase.db" }); + defer allocator.free(db_path); + const db_file = try std.fs.cwd().openFile(db_path, .{}); + db_file.close(); +} + +// Storage engine thread safety properties +test "storage: thread-safe engine access" { + const allocator = testing.allocator; + var fields_arr = [_]sth.Field{schema_helpers.makeField("val", .text)}; + const table = schema_helpers.makeTable("test", &fields_arr); + var ctx: sth.EngineTestContext = undefined; + try sth.setupEngine(&ctx, allocator, "prop-multi-table", table); + defer ctx.deinit(); + const engine = &ctx.engine; + + // Concurrent writes and reads + const num_threads = 10; + const ops_per_thread = 50; + const ThreadContext = struct { + ctx: *sth.EngineTestContext, + }; + const WriteThread = struct { + fn run(t_ctx: ThreadContext, thread_id: usize) !void { + var i: usize = 0; + while (i < ops_per_thread) : (i += 1) { + const key: u128 = thread_id * 1_000 + i + 1; + const value = try std.fmt.allocPrint( + testing.allocator, + "{{\"thread\":{d},\"op\":{d}}}", + .{ thread_id, i }, + ); + defer testing.allocator.free(value); + try t_ctx.ctx.insertField("test", key, 1, "val", tth.valText(value)); + } + } + }; + const ReadThread = struct { + fn run(eng: *StorageEngine, table_index: usize, thread_id: usize) !void { + var i: usize = 0; + while (i < ops_per_thread) : (i += 1) { + const key: u128 = (thread_id % (num_threads / 2)) * 1_000 + i + 1; + const record = try sth.readDoc(testing.allocator, eng, table_index, key, 1); + defer if (record) |r| r.deinit(testing.allocator); + } + } + }; + + // Spawn write threads + var write_threads: [num_threads / 2]std.Thread = undefined; + for (&write_threads, 0..) |*thread, i| { + thread.* = try std.Thread.spawn(.{}, WriteThread.run, .{ ThreadContext{ .ctx = &ctx }, i }); + } + // Spawn read threads + var read_threads: [num_threads / 2]std.Thread = undefined; + const test_table = try ctx.table("test"); + for (&read_threads, 0..) |*thread, i| { + thread.* = try std.Thread.spawn(.{}, ReadThread.run, .{ engine, test_table.metadata.index, i }); + } + // Wait for all threads + for (write_threads) |thread| { + thread.join(); + } + for (read_threads) |thread| { + thread.join(); + } + // Flush writes and verify data + try engine.flushPendingWrites(); + // Verify some data was written + const record = try test_table.readDoc(allocator, 1, 1); + defer if (record) |r| r.deinit(allocator); + try testing.expect(record != null); +} + +test "storage: connection pool reuse and release" { + const allocator = testing.allocator; + var fields_arr = [_]sth.Field{schema_helpers.makeField("val", .text)}; + const table = schema_helpers.makeTable("test", &fields_arr); + var ctx: sth.EngineTestContext = undefined; + try sth.setupEngine(&ctx, allocator, "prop-many-ns", table); + defer ctx.deinit(); + const engine = &ctx.engine; + + // Set some initial data + { + try ctx.insertText("test", 1, 1, "val", "test1"); + try ctx.insertText("test", 2, 1, "val", "test2"); + } + try engine.flushPendingWrites(); + // Perform many read operations to ensure connections are being reused + // If connections weren't released, we'd run out of connections + const num_operations = 1000; + var i: usize = 0; + const test_table = try ctx.table("test"); + while (i < num_operations) : (i += 1) { + const key: u128 = if (i % 2 == 0) 1 else 2; + const record = try test_table.readDoc(testing.allocator, key, 1); + defer if (record) |r| r.deinit(allocator); + try testing.expect(record != null); + } + // If we got here, connections were properly released and reused +} + +test "storage: persistence round-trip (various types)" { + const allocator = testing.allocator; + var fields_arr = [_]sth.Field{schema_helpers.makeField("val", .text)}; + const table = schema_helpers.makeTable("test", &fields_arr); + var ctx: sth.EngineTestContext = undefined; + try sth.setupEngine(&ctx, allocator, "prop-burst", table); + defer ctx.deinit(); + const engine = &ctx.engine; + + // Test various data types and values + const test_cases = [_]struct { + namespace_id: i64, + id: u128, + value: []const u8, + }{ + .{ .namespace_id = 1, .id = 1, .value = "{\"data\":\"simple\"}" }, + .{ .namespace_id = 1, .id = 2, .value = "{\"user\":{\"name\":\"Alice\",\"age\":30}}" }, + .{ .namespace_id = 2, .id = 3, .value = "[1,2,3,4,5]" }, + .{ .namespace_id = 2, .id = 4, .value = "{}" }, + .{ .namespace_id = 3, .id = 5, .value = "{\"text\":\"Hello 世界 🌍\"}" }, + .{ .namespace_id = 3, .id = 6, .value = "{\"chars\":\"\\\"\\n\\t\\r\"}" }, + }; + // Insert all test cases + for (test_cases) |tc| { + try ctx.insertText("test", tc.id, tc.namespace_id, "val", tc.value); + } + // Flush writes + try engine.flushPendingWrites(); + const test_table = try ctx.table("test"); + // Retrieve and verify all test cases + for (test_cases) |tc| { + const record = try test_table.readDoc(allocator, tc.id, tc.namespace_id); + defer if (record) |r| r.deinit(allocator); + try testing.expect(record != null); + } +} + +test "storage: insert/delete inverse consistency" { + const allocator = testing.allocator; + var fields_arr = [_]sth.Field{schema_helpers.makeField("val", .text)}; + const table = schema_helpers.makeTable("test", &fields_arr); + var ctx: sth.EngineTestContext = undefined; + try sth.setupEngine(&ctx, allocator, "storage-inverse", table); + defer ctx.deinit(); + const engine = &ctx.engine; + + const test_cases = [_]struct { + namespace_id: i64, + id: u128, + value: []const u8, + }{ + .{ .namespace_id = 1, .id = 1, .value = "{\"data\":\"value1\"}" }, + .{ .namespace_id = 1, .id = 2, .value = "{\"data\":\"value2\"}" }, + .{ .namespace_id = 2, .id = 3, .value = "{\"data\":\"value3\"}" }, + }; + const test_table = try ctx.table("test"); + for (test_cases) |tc| { + // Insert + try ctx.insertText("test", tc.id, tc.namespace_id, "val", tc.value); + try engine.flushPendingWrites(); + // Verify it exists + const record1 = try test_table.readDoc(allocator, tc.id, tc.namespace_id); + defer if (record1) |r| r.deinit(allocator); + try testing.expect(record1 != null); + + // Delete + try engine.enqueueWriteOp(.{ .delete = .{ .table_index = test_table.metadata.index, .id = tc.id, .namespace_id = tc.namespace_id } }); + try engine.flushPendingWrites(); + // Verify it's gone + const record2 = try test_table.readDoc(allocator, tc.id, tc.namespace_id); + defer if (record2) |r| r.deinit(allocator); + try testing.expect(record2 == null); + } +} + +test "storage: transaction isolation and consistency" { + const allocator = testing.allocator; + var fields_arr = [_]sth.Field{schema_helpers.makeField("val", .text)}; + const table = schema_helpers.makeTable("test", &fields_arr); + var ctx: sth.EngineTestContext = undefined; + try sth.setupEngine(&ctx, allocator, "storage-txn-isolation", table); + defer ctx.deinit(); + const engine = &ctx.engine; + + // The write thread uses transactions internally to ensure atomicity + // Set up initial state + { + try ctx.insertText("test", 1, 1, "val", "initial1"); + try ctx.insertText("test", 2, 1, "val", "initial2"); + } + try engine.flushPendingWrites(); + const test_table = try ctx.table("test"); + // Verify initial state + const record1 = try test_table.readDoc(allocator, 1, 1); + defer if (record1) |r| r.deinit(allocator); + try testing.expect(record1 != null); + const record2 = try test_table.readDoc(allocator, 2, 1); + defer if (record2) |r| r.deinit(allocator); + try testing.expect(record2 != null); + // Queue multiple operations that should execute atomically in a batch + { + try ctx.insertText("test", 1, 1, "val", "updated1"); + try ctx.insertText("test", 2, 1, "val", "updated2"); + try ctx.insertText("test", 3, 1, "val", "new3"); + } + // Flush to ensure operations are processed + try engine.flushPendingWrites(); + + const record_up1 = try test_table.readDoc(allocator, 1, 1); + defer if (record_up1) |r| r.deinit(allocator); + + const record_up2 = try test_table.readDoc(allocator, 2, 1); + defer if (record_up2) |r| r.deinit(allocator); + + const record_n3 = try test_table.readDoc(allocator, 3, 1); + defer if (record_n3) |r| r.deinit(allocator); + + try testing.expect(record_up1 != null); + try testing.expect(record_up2 != null); + try testing.expect(record_n3 != null); +} + +test "storage: concurrent batch processing" { + const allocator = testing.allocator; + var fields_arr = [_]sth.Field{schema_helpers.makeField("val", .text)}; + const table = schema_helpers.makeTable("test", &fields_arr); + var ctx: sth.EngineTestContext = undefined; + try sth.setupEngine(&ctx, allocator, "storage-concurrent-batches", table); + defer ctx.deinit(); + const engine = &ctx.engine; + + // Property: Multiple batches can be queued and will be processed sequentially and atomically + const num_batches = 5; + const ops_per_batch = 20; + var j: usize = 0; + while (j < num_batches) : (j += 1) { + var i: usize = 0; + while (i < ops_per_batch) : (i += 1) { + const key: u128 = j * 100 + i + 1; + const value = try std.fmt.allocPrint(allocator, "{{\"batch\":{d},\"op\":{d}}}", .{ j, i }); + defer allocator.free(value); + try ctx.insertText("test", key, 1, "val", value); + } + } + // Flush and verify all operations succeeded atomically + try engine.flushPendingWrites(); + const test_table = try ctx.table("test"); + var total_found: usize = 0; + var k: usize = 0; + while (k < num_batches) : (k += 1) { + var i: usize = 0; + while (i < ops_per_batch) : (i += 1) { + const key: u128 = k * 100 + i + 1; + const record = try test_table.readDoc(allocator, key, 1); + defer if (record) |r| r.deinit(allocator); + if (record != null) total_found += 1; + } + } + try testing.expectEqual(@as(usize, num_batches * ops_per_batch), total_found); +} + +// Property: Database remains consistent after repeated flush operations +test "storage: repeated flush consistency" { + const allocator = testing.allocator; + var fields_arr = [_]sth.Field{schema_helpers.makeField("val", .text)}; + const table = schema_helpers.makeTable("test", &fields_arr); + var ctx: sth.EngineTestContext = undefined; + try sth.setupEngine(&ctx, allocator, "storage-repeated-flush", table); + defer ctx.deinit(); + const engine = &ctx.engine; + + var i: usize = 0; + while (i < 50) : (i += 1) { + try ctx.insertText("test", i + 1, 1, "val", "value"); + if (i % 5 == 0) { + try engine.flushPendingWrites(); + } + } + try engine.flushPendingWrites(); + const test_table = try ctx.table("test"); + var filter = try qth.makeDefaultFilter(allocator); + defer filter.deinit(allocator); + const qres = try test_table.queryDocs(allocator, 1, &filter); + defer qres.deinit(allocator); + try testing.expectEqual(@as(usize, 50), qres.records.len); +} + +// Additional property: Data integrity across engine restarts +test "storage: data persistence across restarts" { + const allocator = testing.allocator; + var context = try sth.TestContext.init(allocator, "storage-restart-persistence"); + defer context.deinit(); + const test_dir = context.test_dir; + + var fields_arr = [_]sth.Field{schema_helpers.makeField("val", .text)}; + const table = schema_helpers.makeTable("test", &fields_arr); + + // Initial run: Insert data + { + var ctx: sth.EngineTestContext = undefined; + try sth.setupEngineWithDir(&ctx, allocator, test_dir, table, .{ .in_memory = false }); + // Use deinitNoCleanup to preserve the test directory for the second run + defer ctx.deinitNoCleanup(); + + try ctx.insertText("test", 1, 1, "val", "persistent-value"); + try ctx.engine.flushPendingWrites(); + } + + // Second run: Verify data is still there + { + var ctx: sth.EngineTestContext = undefined; + try sth.setupEngineWithDir(&ctx, allocator, test_dir, table, .{ .in_memory = false }); + defer ctx.deinit(); + + const test_table = try ctx.table("test"); + const record = try test_table.readDoc(allocator, 1, 1); + defer if (record) |r| r.deinit(allocator); + try testing.expect(record != null); + } +} + +// Property: Schema updates are reflected in persistence logic +test "storage: schema update integrity" { + const allocator = testing.allocator; + var context = try sth.TestContext.init(allocator, "storage-schema-update"); + defer context.deinit(); + const test_dir = context.test_dir; + + var fields_v1 = [_]sth.Field{schema_helpers.makeField("val1", .text)}; + const table_v1 = schema_helpers.makeTable("test", &fields_v1); + + // Run 1: Version 1 schema - insert data + { + var ctx: sth.EngineTestContext = undefined; + try sth.setupEngineWithDir(&ctx, allocator, test_dir, table_v1, .{ .in_memory = false }); + defer ctx.deinitNoCleanup(); + + try ctx.insertText("test", 1, 1, "val1", "value1"); + try ctx.engine.flushPendingWrites(); + } + + // Run 2: Version 2 schema (added field) - verify existing data is still readable + { + var fields_v2 = [_]sth.Field{ + schema_helpers.makeField("val1", .text), + schema_helpers.makeField("val2", .integer), + }; + const table_v2 = schema_helpers.makeTable("test", &fields_v2); + + var ctx: sth.EngineTestContext = undefined; + try sth.setupEngineWithDir(&ctx, allocator, test_dir, table_v2, .{ .in_memory = false }); + defer ctx.deinit(); + + const test_table = try ctx.table("test"); + + // Existing data should be accessible + const record1 = try test_table.readDoc(allocator, 1, 1); + defer if (record1) |r| r.deinit(allocator); + try testing.expect(record1 != null); + + // New data with new field + try ctx.insertInt("test", 2, 1, "val2", 42); + try ctx.engine.flushPendingWrites(); + + const record2 = try test_table.readDoc(allocator, 2, 1); + defer if (record2) |r| r.deinit(allocator); + try testing.expect(record2 != null); + } +} + +// Fuzzy testing of random operations to ensure no crashes +test "storage: random operations fuzzing" { + const allocator = testing.allocator; + var fields_arr = [_]sth.Field{ + schema_helpers.makeField("title", .text), + schema_helpers.makeField("score", .integer), + }; + const table = schema_helpers.makeTable("items", &fields_arr); + var ctx: sth.EngineTestContext = undefined; + try sth.setupEngine(&ctx, allocator, "storage-fuzz", table); + defer ctx.deinit(); + const engine = &ctx.engine; + + var prng = std.Random.DefaultPrng.init(0); + const rand = prng.random(); + + var i: usize = 0; + while (i < 200) : (i += 1) { + const op = rand.intRangeAtMost(u8, 0, 3); + const id: u128 = rand.intRangeAtMost(u128, 1, 50); + const ns: i64 = @intCast(rand.intRangeAtMost(i32, 1, 5)); + + switch (op) { + 0 => { + // Insert/Update + try ctx.insertText("items", id, ns, "title", "fuzzy"); + try ctx.insertInt("items", id, ns, "score", @intCast(i)); + }, + 1 => { + // Delete + try engine.enqueueWriteOp(.{ .delete = .{ .table_index = 0, .id = id, .namespace_id = ns } }); + }, + 2 => { + // Query + const test_table = try ctx.table("items"); + const record = try test_table.readDoc(allocator, id, ns); + if (record) |r| r.deinit(allocator); + }, + 3 => { + // Flush + try engine.flushPendingWrites(); + }, + else => unreachable, + } + } + try engine.flushPendingWrites(); +} diff --git a/src/test_all.zig b/src/test_all.zig index 53d13a02..f97e1d9e 100644 --- a/src/test_all.zig +++ b/src/test_all.zig @@ -73,18 +73,11 @@ test { _ = @import("migration_executor_test.zig"); _ = @import("msgpack_utils_test.zig"); _ = @import("thread_budget_test.zig"); - _ = @import("message_handler_property_test.zig"); - _ = @import("config/loader_property_test.zig"); - _ = @import("checkpoint_worker_property_test.zig"); - _ = @import("storage_engine_stability_property_test.zig"); - _ = @import("storage_engine_property_test.zig"); - _ = @import("server_init_property_test.zig"); - _ = @import("uwebsockets_wrapper_property_test.zig"); - _ = @import("storage_engine_error_property_test.zig"); - _ = @import("logging_property_test.zig"); - _ = @import("msgpack_utils_property_test.zig"); - _ = @import("migration_detector_property_test.zig"); - _ = @import("migration_executor_property_test.zig"); + _ = @import("storage_engine_stability_test.zig"); + _ = @import("server_init_test.zig"); + _ = @import("storage_engine_error_test.zig"); + _ = @import("logging_test.zig"); + _ = @import("migration_detector_test.zig"); _ = @import("contains_array_equivalence_test.zig"); // Integration tests diff --git a/src/uwebsockets_wrapper_property_test.zig b/src/uwebsockets_wrapper_property_test.zig deleted file mode 100644 index 11803fa8..00000000 --- a/src/uwebsockets_wrapper_property_test.zig +++ /dev/null @@ -1,400 +0,0 @@ -const std = @import("std"); - -const helpers = @import("app_test_helpers.zig"); -const WebSocket = @import("uwebsockets_wrapper.zig").WebSocket; -const MessageType = @import("uwebsockets_wrapper.zig").MessageType; -const WebSocketServer = @import("uwebsockets_wrapper.zig").WebSocketServer; -const WebSocketHandlers = @import("uwebsockets_wrapper.zig").WebSocketHandlers; - -const testing = std.testing; -const createMockWebSocket = helpers.createMockWebSocket; -const destroyMockWebSocket = helpers.destroyMockWebSocket; - -// This property test verifies that WebSocket callbacks are invoked for all connection events: -// - on_open callback is invoked when a connection opens -// - on_message callback is invoked when a message is received -// - on_close callback is invoked when a connection closes -// -// The test verifies that: -// 1. Each registered callback is invoked exactly once per event -// 2. Callbacks receive correct parameters (WebSocket pointer, user data) -// 3. Message callbacks receive correct message content and type -// 4. Close callbacks receive correct close code and message -// 5. Callbacks are not invoked if not registered - -test "ws: callbacks invoked for all events" { - const allocator = testing.allocator; - - // Test case structure - const TestCase = struct { - name: []const u8, - register_open: bool, - register_message: bool, - register_close: bool, - expected_open_calls: u32, - expected_message_calls: u32, - expected_close_calls: u32, - }; - - const test_cases = [_]TestCase{ - .{ - .name = "all callbacks registered", - .register_open = true, - .register_message = true, - .register_close = true, - .expected_open_calls = 1, - .expected_message_calls = 1, - .expected_close_calls = 1, - }, - .{ - .name = "only open and message callbacks", - .register_open = true, - .register_message = true, - .register_close = false, - .expected_open_calls = 1, - .expected_message_calls = 1, - .expected_close_calls = 0, - }, - .{ - .name = "only close callback", - .register_open = false, - .register_message = false, - .register_close = true, - .expected_open_calls = 0, - .expected_message_calls = 0, - .expected_close_calls = 1, - }, - .{ - .name = "no callbacks registered", - .register_open = false, - .register_message = false, - .register_close = false, - .expected_open_calls = 0, - .expected_message_calls = 0, - .expected_close_calls = 0, - }, - }; - - for (test_cases) |tc| { - std.log.debug("Running test case: {s}\n", .{tc.name}); - - // Create callback context to track invocations - var ctx = CallbackContext{ - .open_called = 0, - .message_called = 0, - .close_called = 0, - .last_message = null, - .last_message_type = null, - .last_close_code = null, - .last_close_message = null, - .received_user_data = null, - }; - - // Create server - const config = WebSocketServer.Config{ - .port = 8080, - .host = "127.0.0.1", - .ssl = false, - }; - - var server: WebSocketServer = undefined; - try server.init(allocator, config); - defer server.deinit(); - - // Build handlers based on test case - const handlers = WebSocketHandlers{ - .on_open = if (tc.register_open) testOnOpenProperty else null, - .on_message = if (tc.register_message) testOnMessageProperty else null, - .on_close = if (tc.register_close) testOnCloseProperty else null, - }; - - // Register handlers with context as user data - server.registerWebSocketHandlers("/*", handlers, &ctx); - - // Simulate WebSocket events by calling the handlers directly - // In a real integration test, we would connect a client and trigger events - // For property testing, we verify the handler registration and invocation logic - - // Simulate open event - if (tc.register_open) { - var mock_ws = createMockWebSocket(allocator); - if (handlers.on_open) |handler| { - handler(&mock_ws, &ctx); - } - destroyMockWebSocket(allocator, &mock_ws); - } - - // Simulate message event - if (tc.register_message) { - var mock_ws = createMockWebSocket(allocator); - const test_message = "test message"; - if (handlers.on_message) |handler| { - handler(&mock_ws, test_message, .binary, &ctx); - } - destroyMockWebSocket(allocator, &mock_ws); - } - - // Simulate close event - if (tc.register_close) { - var mock_ws = createMockWebSocket(allocator); - const close_message = "connection closed"; - if (handlers.on_close) |handler| { - handler(&mock_ws, 1000, close_message, &ctx); - } - destroyMockWebSocket(allocator, &mock_ws); - } - - // Verify callback invocation counts - try testing.expectEqual(tc.expected_open_calls, ctx.open_called); - try testing.expectEqual(tc.expected_message_calls, ctx.message_called); - try testing.expectEqual(tc.expected_close_calls, ctx.close_called); - - // Verify callback parameters were received correctly - if (tc.register_open) { - try testing.expect(ctx.received_user_data != null); - } - - if (tc.register_message) { - try testing.expect(ctx.last_message != null); - try testing.expect(ctx.last_message_type != null); - try testing.expectEqualStrings("test message", ctx.last_message.?); - try testing.expectEqual(MessageType.binary, ctx.last_message_type.?); - } - - if (tc.register_close) { - try testing.expect(ctx.last_close_code != null); - try testing.expect(ctx.last_close_message != null); - try testing.expectEqual(@as(i32, 1000), ctx.last_close_code.?); - try testing.expectEqualStrings("connection closed", ctx.last_close_message.?); - } - } -} - -test "ws: message callback content and type" { - const allocator = testing.allocator; - - // Test different message types and content - const MessageTest = struct { - content: []const u8, - msg_type: MessageType, - }; - - const message_tests = [_]MessageTest{ - .{ .content = "Hello, WebSocket!", .msg_type = .text }, - .{ .content = "Binary data \x00\x01\x02", .msg_type = .binary }, - .{ .content = "", .msg_type = .text }, // Empty message - .{ .content = "A" ** 1000, .msg_type = .binary }, // Large message - }; - - for (message_tests) |mt| { - var ctx = CallbackContext{ - .open_called = 0, - .message_called = 0, - .close_called = 0, - .last_message = null, - .last_message_type = null, - .last_close_code = null, - .last_close_message = null, - .received_user_data = null, - }; - - const config = WebSocketServer.Config{ - .port = 8080, - .host = "127.0.0.1", - .ssl = false, - }; - - var server: WebSocketServer = undefined; - try server.init(allocator, config); - defer server.deinit(); - - const handlers = WebSocketHandlers{ - .on_message = testOnMessageProperty, - }; - - server.registerWebSocketHandlers("/*", handlers, &ctx); - - // Simulate message event - var mock_ws = createMockWebSocket(allocator); - if (handlers.on_message) |handler| { - handler(&mock_ws, mt.content, mt.msg_type, &ctx); - } - destroyMockWebSocket(allocator, &mock_ws); - - // Verify message was received correctly - try testing.expectEqual(@as(u32, 1), ctx.message_called); - try testing.expect(ctx.last_message != null); - try testing.expect(ctx.last_message_type != null); - try testing.expectEqualStrings(mt.content, ctx.last_message.?); - try testing.expectEqual(mt.msg_type, ctx.last_message_type.?); - } -} - -test "ws: close callback code and message" { - const allocator = testing.allocator; - - // Test different close codes and messages - const CloseTest = struct { - code: i32, - message: []const u8, - }; - - const close_tests = [_]CloseTest{ - .{ .code = 1000, .message = "Normal closure" }, - .{ .code = 1001, .message = "Going away" }, - .{ .code = 1002, .message = "Protocol error" }, - .{ .code = 1003, .message = "Unsupported data" }, - .{ .code = 1006, .message = "Abnormal closure" }, - .{ .code = 1000, .message = "" }, // Empty message - }; - - for (close_tests) |ct| { - var ctx = CallbackContext{ - .open_called = 0, - .message_called = 0, - .close_called = 0, - .last_message = null, - .last_message_type = null, - .last_close_code = null, - .last_close_message = null, - .received_user_data = null, - }; - - const config = WebSocketServer.Config{ - .port = 8080, - .host = "127.0.0.1", - .ssl = false, - }; - - var server: WebSocketServer = undefined; - try server.init(allocator, config); - defer server.deinit(); - - const handlers = WebSocketHandlers{ - .on_close = testOnCloseProperty, - }; - - server.registerWebSocketHandlers("/*", handlers, &ctx); - - // Simulate close event - var mock_ws = createMockWebSocket(allocator); - if (handlers.on_close) |handler| { - handler(&mock_ws, ct.code, ct.message, &ctx); - } - destroyMockWebSocket(allocator, &mock_ws); - - // Verify close parameters were received correctly - try testing.expectEqual(@as(u32, 1), ctx.close_called); - try testing.expect(ctx.last_close_code != null); - try testing.expect(ctx.last_close_message != null); - try testing.expectEqual(ct.code, ctx.last_close_code.?); - try testing.expectEqualStrings(ct.message, ctx.last_close_message.?); - } -} - -test "ws: callbacks invoked exactly once" { - const allocator = testing.allocator; - - var ctx = CallbackContext{ - .open_called = 0, - .message_called = 0, - .close_called = 0, - .last_message = null, - .last_message_type = null, - .last_close_code = null, - .last_close_message = null, - .received_user_data = null, - }; - - const config = WebSocketServer.Config{ - .port = 8080, - .host = "127.0.0.1", - .ssl = false, - }; - - var server: WebSocketServer = undefined; - try server.init(allocator, config); - defer server.deinit(); - - const handlers = WebSocketHandlers{ - .on_open = testOnOpenProperty, - .on_message = testOnMessageProperty, - .on_close = testOnCloseProperty, - }; - - server.registerWebSocketHandlers("/*", handlers, &ctx); - - // Simulate multiple events - var mock_ws = createMockWebSocket(allocator); - - // Open event - if (handlers.on_open) |handler| { - handler(&mock_ws, &ctx); - } - try testing.expectEqual(@as(u32, 1), ctx.open_called); - - // Multiple message events - if (handlers.on_message) |handler| { - handler(&mock_ws, "message 1", .binary, &ctx); - handler(&mock_ws, "message 2", .text, &ctx); - handler(&mock_ws, "message 3", .binary, &ctx); - } - try testing.expectEqual(@as(u32, 3), ctx.message_called); - - // Close event - if (handlers.on_close) |handler| { - handler(&mock_ws, 1000, "closing", &ctx); - } - try testing.expectEqual(@as(u32, 1), ctx.close_called); - - // Verify total invocations - try testing.expectEqual(@as(u32, 1), ctx.open_called); - try testing.expectEqual(@as(u32, 3), ctx.message_called); - try testing.expectEqual(@as(u32, 1), ctx.close_called); - - destroyMockWebSocket(allocator, &mock_ws); -} - -// Helper types and functions - -const CallbackContext = struct { - open_called: u32, - message_called: u32, - close_called: u32, - last_message: ?[]const u8, - last_message_type: ?MessageType, - last_close_code: ?i32, - last_close_message: ?[]const u8, - received_user_data: ?*anyopaque, -}; - -fn testOnOpenProperty(ws: *WebSocket, user_data: ?*anyopaque) void { - _ = ws; - if (user_data) |data| { - const ctx: *CallbackContext = @ptrCast(@alignCast(data)); - ctx.open_called += 1; - ctx.received_user_data = data; - } -} - -fn testOnMessageProperty(ws: *WebSocket, message: []const u8, msg_type: MessageType, user_data: ?*anyopaque) void { - _ = ws; - if (user_data) |data| { - const ctx: *CallbackContext = @ptrCast(@alignCast(data)); - ctx.message_called += 1; - ctx.last_message = message; - ctx.last_message_type = msg_type; - ctx.received_user_data = data; - } -} - -fn testOnCloseProperty(ws: *WebSocket, code: i32, message: []const u8, user_data: ?*anyopaque) void { - _ = ws; - if (user_data) |data| { - const ctx: *CallbackContext = @ptrCast(@alignCast(data)); - ctx.close_called += 1; - ctx.last_close_code = code; - ctx.last_close_message = message; - ctx.received_user_data = data; - } -} diff --git a/src/uwebsockets_wrapper_test.zig b/src/uwebsockets_wrapper_test.zig index baac408e..03fe406b 100644 --- a/src/uwebsockets_wrapper_test.zig +++ b/src/uwebsockets_wrapper_test.zig @@ -1,5 +1,6 @@ const std = @import("std"); +const helpers = @import("app_test_helpers.zig"); const WebSocket = @import("uwebsockets_wrapper.zig").WebSocket; const MessageType = @import("uwebsockets_wrapper.zig").MessageType; const WebSocketServer = @import("uwebsockets_wrapper.zig").WebSocketServer; @@ -235,3 +236,394 @@ test "WebSocketServer: full server lifecycle with SSL" { try runFullLifecycleTest(allocator, config, "wss://127.0.0.1:9006/", true); } + +const createMockWebSocket = helpers.createMockWebSocket; +const destroyMockWebSocket = helpers.destroyMockWebSocket; +// This property test verifies that WebSocket callbacks are invoked for all connection events: +// - on_open callback is invoked when a connection opens +// - on_message callback is invoked when a message is received +// - on_close callback is invoked when a connection closes +// +// The test verifies that: +// 1. Each registered callback is invoked exactly once per event +// 2. Callbacks receive correct parameters (WebSocket pointer, user data) +// 3. Message callbacks receive correct message content and type +// 4. Close callbacks receive correct close code and message +// 5. Callbacks are not invoked if not registered + +test "ws: callbacks invoked for all events" { + const allocator = testing.allocator; + + // Test case structure + const TestCase = struct { + name: []const u8, + register_open: bool, + register_message: bool, + register_close: bool, + expected_open_calls: u32, + expected_message_calls: u32, + expected_close_calls: u32, + }; + + const test_cases = [_]TestCase{ + .{ + .name = "all callbacks registered", + .register_open = true, + .register_message = true, + .register_close = true, + .expected_open_calls = 1, + .expected_message_calls = 1, + .expected_close_calls = 1, + }, + .{ + .name = "only open and message callbacks", + .register_open = true, + .register_message = true, + .register_close = false, + .expected_open_calls = 1, + .expected_message_calls = 1, + .expected_close_calls = 0, + }, + .{ + .name = "only close callback", + .register_open = false, + .register_message = false, + .register_close = true, + .expected_open_calls = 0, + .expected_message_calls = 0, + .expected_close_calls = 1, + }, + .{ + .name = "no callbacks registered", + .register_open = false, + .register_message = false, + .register_close = false, + .expected_open_calls = 0, + .expected_message_calls = 0, + .expected_close_calls = 0, + }, + }; + + for (test_cases) |tc| { + std.log.debug("Running test case: {s}\n", .{tc.name}); + + // Create callback context to track invocations + var ctx = CallbackContext{ + .open_called = 0, + .message_called = 0, + .close_called = 0, + .last_message = null, + .last_message_type = null, + .last_close_code = null, + .last_close_message = null, + .received_user_data = null, + }; + + // Create server + const config = WebSocketServer.Config{ + .port = 8080, + .host = "127.0.0.1", + .ssl = false, + }; + + var server: WebSocketServer = undefined; + try server.init(allocator, config); + defer server.deinit(); + + // Build handlers based on test case + const handlers = WebSocketHandlers{ + .on_open = if (tc.register_open) testOnOpenProperty else null, + .on_message = if (tc.register_message) testOnMessageProperty else null, + .on_close = if (tc.register_close) testOnCloseProperty else null, + }; + + // Register handlers with context as user data + server.registerWebSocketHandlers("/*", handlers, &ctx); + + // Simulate WebSocket events by calling the handlers directly + // In a real integration test, we would connect a client and trigger events + // For property testing, we verify the handler registration and invocation logic + + // Simulate open event + if (tc.register_open) { + var mock_ws = createMockWebSocket(allocator); + if (handlers.on_open) |handler| { + handler(&mock_ws, &ctx); + } + destroyMockWebSocket(allocator, &mock_ws); + } + + // Simulate message event + if (tc.register_message) { + var mock_ws = createMockWebSocket(allocator); + const test_message = "test message"; + if (handlers.on_message) |handler| { + handler(&mock_ws, test_message, .binary, &ctx); + } + destroyMockWebSocket(allocator, &mock_ws); + } + + // Simulate close event + if (tc.register_close) { + var mock_ws = createMockWebSocket(allocator); + const close_message = "connection closed"; + if (handlers.on_close) |handler| { + handler(&mock_ws, 1000, close_message, &ctx); + } + destroyMockWebSocket(allocator, &mock_ws); + } + + // Verify callback invocation counts + try testing.expectEqual(tc.expected_open_calls, ctx.open_called); + try testing.expectEqual(tc.expected_message_calls, ctx.message_called); + try testing.expectEqual(tc.expected_close_calls, ctx.close_called); + + // Verify callback parameters were received correctly + if (tc.register_open) { + try testing.expect(ctx.received_user_data != null); + } + + if (tc.register_message) { + try testing.expect(ctx.last_message != null); + try testing.expect(ctx.last_message_type != null); + try testing.expectEqualStrings("test message", ctx.last_message.?); + try testing.expectEqual(MessageType.binary, ctx.last_message_type.?); + } + + if (tc.register_close) { + try testing.expect(ctx.last_close_code != null); + try testing.expect(ctx.last_close_message != null); + try testing.expectEqual(@as(i32, 1000), ctx.last_close_code.?); + try testing.expectEqualStrings("connection closed", ctx.last_close_message.?); + } + } +} + +test "ws: message callback content and type" { + const allocator = testing.allocator; + + // Test different message types and content + const MessageTest = struct { + content: []const u8, + msg_type: MessageType, + }; + + const message_tests = [_]MessageTest{ + .{ .content = "Hello, WebSocket!", .msg_type = .text }, + .{ .content = "Binary data \x00\x01\x02", .msg_type = .binary }, + .{ .content = "", .msg_type = .text }, // Empty message + .{ .content = "A" ** 1000, .msg_type = .binary }, // Large message + }; + + for (message_tests) |mt| { + var ctx = CallbackContext{ + .open_called = 0, + .message_called = 0, + .close_called = 0, + .last_message = null, + .last_message_type = null, + .last_close_code = null, + .last_close_message = null, + .received_user_data = null, + }; + + const config = WebSocketServer.Config{ + .port = 8080, + .host = "127.0.0.1", + .ssl = false, + }; + + var server: WebSocketServer = undefined; + try server.init(allocator, config); + defer server.deinit(); + + const handlers = WebSocketHandlers{ + .on_message = testOnMessageProperty, + }; + + server.registerWebSocketHandlers("/*", handlers, &ctx); + + // Simulate message event + var mock_ws = createMockWebSocket(allocator); + if (handlers.on_message) |handler| { + handler(&mock_ws, mt.content, mt.msg_type, &ctx); + } + destroyMockWebSocket(allocator, &mock_ws); + + // Verify message was received correctly + try testing.expectEqual(@as(u32, 1), ctx.message_called); + try testing.expect(ctx.last_message != null); + try testing.expect(ctx.last_message_type != null); + try testing.expectEqualStrings(mt.content, ctx.last_message.?); + try testing.expectEqual(mt.msg_type, ctx.last_message_type.?); + } +} + +test "ws: close callback code and message" { + const allocator = testing.allocator; + + // Test different close codes and messages + const CloseTest = struct { + code: i32, + message: []const u8, + }; + + const close_tests = [_]CloseTest{ + .{ .code = 1000, .message = "Normal closure" }, + .{ .code = 1001, .message = "Going away" }, + .{ .code = 1002, .message = "Protocol error" }, + .{ .code = 1003, .message = "Unsupported data" }, + .{ .code = 1006, .message = "Abnormal closure" }, + .{ .code = 1000, .message = "" }, // Empty message + }; + + for (close_tests) |ct| { + var ctx = CallbackContext{ + .open_called = 0, + .message_called = 0, + .close_called = 0, + .last_message = null, + .last_message_type = null, + .last_close_code = null, + .last_close_message = null, + .received_user_data = null, + }; + + const config = WebSocketServer.Config{ + .port = 8080, + .host = "127.0.0.1", + .ssl = false, + }; + + var server: WebSocketServer = undefined; + try server.init(allocator, config); + defer server.deinit(); + + const handlers = WebSocketHandlers{ + .on_close = testOnCloseProperty, + }; + + server.registerWebSocketHandlers("/*", handlers, &ctx); + + // Simulate close event + var mock_ws = createMockWebSocket(allocator); + if (handlers.on_close) |handler| { + handler(&mock_ws, ct.code, ct.message, &ctx); + } + destroyMockWebSocket(allocator, &mock_ws); + + // Verify close parameters were received correctly + try testing.expectEqual(@as(u32, 1), ctx.close_called); + try testing.expect(ctx.last_close_code != null); + try testing.expect(ctx.last_close_message != null); + try testing.expectEqual(ct.code, ctx.last_close_code.?); + try testing.expectEqualStrings(ct.message, ctx.last_close_message.?); + } +} + +test "ws: callbacks invoked exactly once" { + const allocator = testing.allocator; + + var ctx = CallbackContext{ + .open_called = 0, + .message_called = 0, + .close_called = 0, + .last_message = null, + .last_message_type = null, + .last_close_code = null, + .last_close_message = null, + .received_user_data = null, + }; + + const config = WebSocketServer.Config{ + .port = 8080, + .host = "127.0.0.1", + .ssl = false, + }; + + var server: WebSocketServer = undefined; + try server.init(allocator, config); + defer server.deinit(); + + const handlers = WebSocketHandlers{ + .on_open = testOnOpenProperty, + .on_message = testOnMessageProperty, + .on_close = testOnCloseProperty, + }; + + server.registerWebSocketHandlers("/*", handlers, &ctx); + + // Simulate multiple events + var mock_ws = createMockWebSocket(allocator); + + // Open event + if (handlers.on_open) |handler| { + handler(&mock_ws, &ctx); + } + try testing.expectEqual(@as(u32, 1), ctx.open_called); + + // Multiple message events + if (handlers.on_message) |handler| { + handler(&mock_ws, "message 1", .binary, &ctx); + handler(&mock_ws, "message 2", .text, &ctx); + handler(&mock_ws, "message 3", .binary, &ctx); + } + try testing.expectEqual(@as(u32, 3), ctx.message_called); + + // Close event + if (handlers.on_close) |handler| { + handler(&mock_ws, 1000, "closing", &ctx); + } + try testing.expectEqual(@as(u32, 1), ctx.close_called); + + // Verify total invocations + try testing.expectEqual(@as(u32, 1), ctx.open_called); + try testing.expectEqual(@as(u32, 3), ctx.message_called); + try testing.expectEqual(@as(u32, 1), ctx.close_called); + + destroyMockWebSocket(allocator, &mock_ws); +} + +// Helper types and functions + +const CallbackContext = struct { + open_called: u32, + message_called: u32, + close_called: u32, + last_message: ?[]const u8, + last_message_type: ?MessageType, + last_close_code: ?i32, + last_close_message: ?[]const u8, + received_user_data: ?*anyopaque, +}; + +fn testOnOpenProperty(ws: *WebSocket, user_data: ?*anyopaque) void { + _ = ws; + if (user_data) |data| { + const ctx: *CallbackContext = @ptrCast(@alignCast(data)); + ctx.open_called += 1; + ctx.received_user_data = data; + } +} + +fn testOnMessageProperty(ws: *WebSocket, message: []const u8, msg_type: MessageType, user_data: ?*anyopaque) void { + _ = ws; + if (user_data) |data| { + const ctx: *CallbackContext = @ptrCast(@alignCast(data)); + ctx.message_called += 1; + ctx.last_message = message; + ctx.last_message_type = msg_type; + ctx.received_user_data = data; + } +} + +fn testOnCloseProperty(ws: *WebSocket, code: i32, message: []const u8, user_data: ?*anyopaque) void { + _ = ws; + if (user_data) |data| { + const ctx: *CallbackContext = @ptrCast(@alignCast(data)); + ctx.close_called += 1; + ctx.last_close_code = code; + ctx.last_close_message = message; + ctx.received_user_data = data; + } +} diff --git a/tests/e2e/src/harness.ts b/tests/e2e/src/harness.ts index 3b58867b..cec00709 100644 --- a/tests/e2e/src/harness.ts +++ b/tests/e2e/src/harness.ts @@ -199,7 +199,6 @@ function removeDir(dir: string) { function isRuntimeSourceFile(fileName: string): boolean { if ( fileName.endsWith("_test.zig") || - fileName.endsWith("_property_test.zig") || fileName.endsWith("_test_helpers.zig") || fileName === "test_all.zig" || fileName === "timed_test_runner.zig" From 0a7ed17c91e67f6a42e262b284d2076e262b1268 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20Dokumac=C4=B1?= Date: Sun, 2 Aug 2026 10:14:53 +0200 Subject: [PATCH 2/8] chore: address review comments --- scripts/unused_pub_detector.js | 27 +- src/checkpoint_worker_test.zig | 88 +---- src/config/loader_test.zig | 120 ------- src/logging_test.zig | 290 ++-------------- src/message_handler_test.zig | 21 +- src/migration_detector_test.zig | 409 +++++++++++----------- src/migration_executor_test.zig | 466 +++++++++++++------------- src/msgpack_utils_test.zig | 285 ++++++++-------- src/server_init_test.zig | 35 +- src/storage_engine_error_test.zig | 22 +- src/storage_engine_stability_test.zig | 8 +- src/storage_engine_test.zig | 106 ++++-- src/uwebsockets_wrapper_test.zig | 90 +---- 13 files changed, 787 insertions(+), 1180 deletions(-) diff --git a/scripts/unused_pub_detector.js b/scripts/unused_pub_detector.js index e9764770..8fe4d9ae 100644 --- a/scripts/unused_pub_detector.js +++ b/scripts/unused_pub_detector.js @@ -3,7 +3,32 @@ const fs = require("fs"), path = require("path"); const SRC = path.resolve(__dirname, "..", "src"); const EXTERNAL = new Set(["std","builtin","sqlite","msgpack","httpx"]); -const TEST_RE = /test/; +// Boundary-aware test-path classification on canonical paths ("src/foo_test.zig"): +// *_test.zig suffix, test_all.zig, *test_helper(s).zig, *test_runner.zig. +// Does NOT match ordinary production names such as "latest.zig". +const TEST_RE = /(?:^|\/)(?:[^/]+_test\.zig|test_all\.zig|[^/]*test[-_](?:helper|runner)[^/]*\.zig)$/; + +if (process.argv.includes("--selftest")) { + const cases = [ + ["src/logging_test.zig", true], + ["src/test_all.zig", true], + ["src/app_test_helpers.zig", true], + ["src/schema/test_helpers.zig", true], + ["src/timed_test_runner.zig", true], + ["src/latest.zig", false], + ["src/main.zig", false], + ["src/server.zig", false], + ]; + for (const [p, expected] of cases) { + const actual = TEST_RE.test(p); + if (actual !== expected) { + console.error(`FAIL: TEST_RE("${p}") = ${actual}, expected ${expected}`); + process.exit(1); + } + } + console.log("selftest: all path classifications OK"); + process.exit(0); +} function log(m) { process.stderr.write(m + "\n"); } diff --git a/src/checkpoint_worker_test.zig b/src/checkpoint_worker_test.zig index 519bff50..4923744b 100644 --- a/src/checkpoint_worker_test.zig +++ b/src/checkpoint_worker_test.zig @@ -162,6 +162,9 @@ test "CheckpointWorker: performCheckpoint - metrics update" { // Verify timestamp was updated try testing.expect(metrics_after.last_checkpoint_time >= metrics_before.last_checkpoint_time); + + // Duration may be 0 for very fast runs; verify value is valid. + try testing.expect(metrics_after.last_checkpoint_duration_ms >= 0); } test "CheckpointWorker: getMetrics" { @@ -373,11 +376,10 @@ test "checkpoint: WAL size management - size decreases or stays same after succe const result = try manager.performCheckpoint(.truncate); - if (result.success) { - // WAL size after should be <= WAL size before - try testing.expect(result.wal_size_after <= result.wal_size_before); - try testing.expect(result.wal_size_before == initial_wal_size); - } + // WAL size after should be <= WAL size before + try testing.expect(result.success); + try testing.expect(result.wal_size_after <= result.wal_size_before); + try testing.expect(result.wal_size_before == initial_wal_size); } test "checkpoint: threshold detection - shouldCheckpoint respects thresholds" { @@ -407,7 +409,7 @@ test "checkpoint: threshold detection - shouldCheckpoint respects thresholds" { try testing.expect(manager.shouldCheckpoint()); } -test "checkpoint: failure handling - failed checkpoints increment counter" { +test "checkpoint: failure handling - failure counter starts at zero" { const allocator = testing.allocator; var ctx: checkpoint_helpers.Context = undefined; @@ -420,45 +422,13 @@ test "checkpoint: failure handling - failed checkpoints increment counter" { const manager = &ctx.manager; - // Property: Failed checkpoints increment failure counter + // Property: The failure counter starts at zero on a fresh manager + // (no failure injection hook exists, so this test verifies the initial state) const initial_failures = manager.failed_checkpoint_count.load(.acquire); - // Note: In real implementation, we would inject a failure here - // For now, we just verify the counter exists and can be read try testing.expect(initial_failures == 0); } -test "checkpoint: metrics accuracy - metrics reflect operations accurately" { - const allocator = testing.allocator; - - var ctx: checkpoint_helpers.Context = undefined; - try ctx.init(allocator, .{ - .wal_size_threshold = 1024, - .time_threshold_sec = 300, - .checkpoint_mode = .passive, - }); - defer ctx.deinit(); - - const manager = &ctx.manager; - - // Property: Metrics accurately reflect checkpoint operations - const metrics_before = manager.getMetrics(); - - // Perform checkpoint - _ = try manager.performCheckpoint(.passive); - - const metrics_after = manager.getMetrics(); - - // Verify checkpoint count increased - try testing.expect(metrics_after.checkpoint_count == metrics_before.checkpoint_count + 1); - - // Verify timestamp updated - try testing.expect(metrics_after.last_checkpoint_time >= metrics_before.last_checkpoint_time); - - // Duration may be 0 for very fast runs; verify value is valid. - try testing.expect(metrics_after.last_checkpoint_duration_ms >= 0); -} - test "checkpoint: escalation logic - works correctly when needed" { const allocator = testing.allocator; @@ -472,43 +442,15 @@ test "checkpoint: escalation logic - works correctly when needed" { const manager = &ctx.manager; - // Property: Escalation logic works correctly - // Set up scenario where passive checkpoint doesn't reduce WAL much - manager.wal_size.store(10000, .release); - + // Property: Escalation logic works correctly. + // The in-memory WAL size is 0, so a passive checkpoint cannot reduce the + // WAL by >=10% and the worker must escalate to full mode. const result = try manager.performCheckpointWithEscalation(); // Verify checkpoint was attempted (success flag should be set) try testing.expect(result.success); + // Verify escalation actually occurred (passive would mean no escalation) + try testing.expect(result.mode != .passive); // Duration may be 0 in fast runs, but should be >= 0 try testing.expect(result.duration_ms >= 0); } - -test "checkpoint: Prometheus formatting - output contains all required metrics" { - const allocator = testing.allocator; - - const metrics = CheckpointWorker.CheckpointMetrics{ - .last_checkpoint_time = 1234567890, - .last_checkpoint_duration_ms = 150, - .wal_size_bytes = 5000000, - .checkpoint_count = 42, - .failed_checkpoint_count = 3, - }; - - const prometheus_output = try metrics.toPrometheus(allocator); - defer allocator.free(prometheus_output); - - // Property: Prometheus output contains all required metrics - try testing.expect(std.mem.indexOf(u8, prometheus_output, "zyncbase_checkpoint_last_time_seconds") != null); - try testing.expect(std.mem.indexOf(u8, prometheus_output, "zyncbase_checkpoint_last_duration_ms") != null); - try testing.expect(std.mem.indexOf(u8, prometheus_output, "zyncbase_wal_size_bytes") != null); - try testing.expect(std.mem.indexOf(u8, prometheus_output, "zyncbase_checkpoint_total") != null); - try testing.expect(std.mem.indexOf(u8, prometheus_output, "zyncbase_checkpoint_failed_total") != null); - - // Property: Prometheus output contains correct values - try testing.expect(std.mem.indexOf(u8, prometheus_output, "1234567890") != null); - try testing.expect(std.mem.indexOf(u8, prometheus_output, "150") != null); - try testing.expect(std.mem.indexOf(u8, prometheus_output, "5000000") != null); - try testing.expect(std.mem.indexOf(u8, prometheus_output, "42") != null); - try testing.expect(std.mem.indexOf(u8, prometheus_output, "3") != null); -} diff --git a/src/config/loader_test.zig b/src/config/loader_test.zig index 8baf2144..ac0f1f1b 100644 --- a/src/config/loader_test.zig +++ b/src/config/loader_test.zig @@ -641,89 +641,6 @@ test "config: round-trip - server config" { try std.testing.expectEqualStrings("127.0.0.1", config.server.host); } -test "config: round-trip - auth config" { - const allocator = std.testing.allocator; - - var context = try schema_helpers.TestContext.init(allocator, "config-roundtrip-auth"); - defer context.deinit(); - - const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-roundtrip-auth.json" }); - defer allocator.free(temp_file_path); - const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-roundtrip-auth.json" }); - defer allocator.free(schema_file_path); - - const config_content = try std.fmt.allocPrint(allocator, - \\{{ - \\ "authentication": {{ - \\ "jwt": {{ - \\ "secret": "my-secret-key", - \\ "algorithm": "HS512", - \\ "issuer": "zyncbase", - \\ "audience": "api" - \\ }} - \\ }}, - \\ "schema": "{s}" - \\}} - , .{schema_file_path}); - defer allocator.free(config_content); - - try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); - try std.fs.cwd().writeFile(.{ .sub_path = schema_file_path, .data = "{}" }); - - var config = try ConfigLoader.load(allocator, temp_file_path); - defer config.deinit(); - - // Verify values match original - try std.testing.expect(config.authentication.jwt_secret != null); - try std.testing.expectEqualStrings("my-secret-key", config.authentication.jwt_secret.?); - try std.testing.expectEqualStrings("HS512", config.authentication.jwt_algorithm); - try std.testing.expect(config.authentication.jwt_issuer != null); - try std.testing.expectEqualStrings("zyncbase", config.authentication.jwt_issuer.?); - try std.testing.expect(config.authentication.jwt_audience != null); - try std.testing.expectEqualStrings("api", config.authentication.jwt_audience.?); -} - -test "config: round-trip - security config" { - const allocator = std.testing.allocator; - - var context = try schema_helpers.TestContext.init(allocator, "config-roundtrip-security"); - defer context.deinit(); - - const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-roundtrip-security.json" }); - defer allocator.free(temp_file_path); - const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-roundtrip-security.json" }); - defer allocator.free(schema_file_path); - - const config_content = try std.fmt.allocPrint(allocator, - \\{{ - \\ "security": {{ - \\ "allowedOrigins": ["https://example.com", "https://app.example.com"], - \\ "allowLocalhost": false, - \\ "maxMessagesPerSecond": 200, - \\ "maxConnections": 20, - \\ "maxMessageSize": 2097152 - \\ }}, - \\ "schema": "{s}" - \\}} - , .{schema_file_path}); - defer allocator.free(config_content); - - try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); - try std.fs.cwd().writeFile(.{ .sub_path = schema_file_path, .data = "{}" }); - - var config = try ConfigLoader.load(allocator, temp_file_path); - defer config.deinit(); - - // Verify values match original - try std.testing.expectEqual(@as(usize, 2), config.security.allowed_origins.len); - try std.testing.expectEqualStrings("https://example.com", config.security.allowed_origins[0]); - try std.testing.expectEqualStrings("https://app.example.com", config.security.allowed_origins[1]); - try std.testing.expectEqual(false, config.security.allow_localhost); - try std.testing.expectEqual(@as(u32, 200), config.security.max_messages_per_second); - try std.testing.expectEqual(@as(u32, 20), config.security.max_connections); - try std.testing.expectEqual(@as(usize, 2097152), config.security.max_message_size); -} - test "config: round-trip - logging config" { const allocator = std.testing.allocator; @@ -757,43 +674,6 @@ test "config: round-trip - logging config" { try std.testing.expectEqual(Config.LoggingConfig.LogFormat.text, config.logging.format); } -test "config: round-trip - performance config" { - const allocator = std.testing.allocator; - - var context = try schema_helpers.TestContext.init(allocator, "config-roundtrip-perf"); - defer context.deinit(); - - const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-roundtrip-performance.json" }); - defer allocator.free(temp_file_path); - const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-roundtrip-performance.json" }); - defer allocator.free(schema_file_path); - - const config_content = try std.fmt.allocPrint(allocator, - \\{{ - \\ "performance": {{ - \\ "messageBufferSize": 2000, - \\ "batchWrites": false, - \\ "batchSize": 50, - \\ "batchTimeout": 20 - \\ }}, - \\ "schema": "{s}" - \\}} - , .{schema_file_path}); - defer allocator.free(config_content); - - try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); - try std.fs.cwd().writeFile(.{ .sub_path = schema_file_path, .data = "{}" }); - - var config = try ConfigLoader.load(allocator, temp_file_path); - defer config.deinit(); - - // Verify values match original - try std.testing.expectEqual(@as(usize, 2000), config.performance.message_buffer_size); - try std.testing.expectEqual(false, config.performance.batch_writes); - try std.testing.expectEqual(@as(usize, 50), config.performance.batch_size); - try std.testing.expectEqual(@as(u32, 20), config.performance.batch_timeout); -} - test "config: round-trip - complete config" { const allocator = std.testing.allocator; diff --git a/src/logging_test.zig b/src/logging_test.zig index af22b76d..45a5f064 100644 --- a/src/logging_test.zig +++ b/src/logging_test.zig @@ -1,72 +1,17 @@ const std = @import("std"); const helpers = @import("app_test_helpers.zig"); -const authorization_defaults = @import("authorization/defaults.zig"); const connection_manager = @import("connection/manager.zig"); -const connection_violations = @import("connection/violations.zig"); -const MemoryStrategy = @import("memory/strategy.zig").MemoryStrategy; -const MessageHandler = @import("message_handler.zig").MessageHandler; const msgpack_helpers = @import("msgpack_test_helpers.zig"); -const PresenceService = @import("presence/service.zig").PresenceService; -const schema_helpers = @import("schema/test_helpers.zig"); -const schema_types = @import("schema/types.zig"); -const StorageEngine = @import("storage_engine.zig").StorageEngine; const sth = @import("storage_engine_test_helpers.zig"); -const StoreService = @import("store_service.zig").StoreService; -const SubscriptionEngine = @import("subscription/engine.zig").SubscriptionEngine; const WebSocket = @import("uwebsockets_wrapper.zig").WebSocket; const testing = std.testing; const createMockWebSocket = helpers.createMockWebSocket; const AppTestContext = helpers.AppTestContext; -const ViolationTracker = connection_violations.ConnectionViolationTracker; const ConnectionManager = connection_manager.ConnectionManager; -// Custom log handler to capture log messages for testing -const LogCapture = struct { - allocator: std.mem.Allocator, - messages: std.ArrayListUnmanaged(LogMessage) = .empty, - mutex: std.Thread.Mutex, - - const LogMessage = struct { - level: std.log.Level, - message: []const u8, - allocator: std.mem.Allocator, - - fn deinit(self: *LogMessage) void { - self.allocator.free(self.message); - } - }; - - fn init(allocator: std.mem.Allocator) LogCapture { - return .{ - .allocator = allocator, - .messages = .empty, - .mutex = .{}, - }; - } - - fn deinit(self: *LogCapture) void { - for (self.messages.items) |*msg| { - msg.deinit(); - } - self.messages.deinit(self.allocator); - } - - fn contains(self: *LogCapture, needle: []const u8) bool { - self.mutex.lock(); - defer self.mutex.unlock(); - - for (self.messages.items) |msg| { - if (std.mem.indexOf(u8, msg.message, needle) != null) { - return true; - } - } - return false; - } -}; - test "logging: connection events" { // Connection event logging properties // @@ -151,22 +96,6 @@ test "logging: connection events" { } } - // Test 4: Error handling logs connection ID - { - var ws = createMockWebSocket(memory_strategy.generalAllocator()); - - // Open connection - try manager.onOpen(&ws); - const conn_id = ws.getConnId(); - - // Handle error - this should log "WebSocket error on connection: id={}" - manager.onClose(&ws); - - // Verify connection was cleaned up - const result = manager.acquireConnection(conn_id); - try testing.expectError(error.ConnectionNotFound, result); - } - // Test 5: Concurrent connections all log { const ThreadContext = struct { @@ -319,84 +248,23 @@ test "logging: level filtering" { // Test 1: Verify different log levels are used appropriately { - var memory_strategy: MemoryStrategy = undefined; - try memory_strategy.init(testing.allocator); - defer std.debug.assert(memory_strategy.deinit() == .ok); - - var tracker: ViolationTracker = undefined; - tracker.init(allocator, 10); - defer tracker.deinit(); - - var context = try schema_helpers.TestContext.init(allocator, "logging-level"); - defer context.deinit(); - const test_dir = context.test_dir; - - var fields = [_]schema_types.Field{schema_helpers.makeField("val", .text)}; - var tables = try allocator.alloc(schema_types.Table, 1); - defer allocator.free(tables); - tables[0] = schema_helpers.makeTable("test", &fields); - var sm2 = try sth.createSchema(allocator, tables); - defer sm2.deinit(); - - var subscription_engine: SubscriptionEngine = SubscriptionEngine.init(allocator); - defer subscription_engine.deinit(); - - var storage_engine: StorageEngine = undefined; - try storage_engine.init(allocator, &memory_strategy, test_dir, &sm2, .{}, .{ .in_memory = true, .reader_pool_size = 1 }, null, null); - defer storage_engine.deinit(); - - var auth_config = try authorization_defaults.implicitConfig(allocator, &sm2); - defer auth_config.deinit(); - - var store_service = StoreService.init(allocator, &storage_engine, &sm2, &auth_config); - defer store_service.deinit(); - - var presence_service = PresenceService.init(allocator, null, &auth_config, &sm2); - defer presence_service.deinit(); - - var handler: MessageHandler = undefined; - const empty_claims: std.StringHashMapUnmanaged([]const u8) = .{}; - handler.init( - allocator, - &memory_strategy, - &tracker, - &store_service, - &presence_service, - &subscription_engine, - .{}, - &auth_config, - &sm2, - null, - &empty_claims, - ); - defer handler.deinit(); - - var manager: ConnectionManager = undefined; - try manager.init(allocator, &memory_strategy, &handler, &sm2, 100_000, 30); - defer manager.deinit(); + var app: AppTestContext = undefined; + try app.init(allocator, "logging-level", &.{ + .{ .name = "test", .fields = &.{"val"} }, + }); + defer app.deinit(); // Trigger different log levels - var ws = createMockWebSocket(memory_strategy.generalAllocator()); + var ws = createMockWebSocket(app.memory_strategy.generalAllocator()); // Info level: connection open - try manager.onOpen(&ws); + try app.connection_manager.onOpen(&ws); // Warn level: invalid message - manager.onMessage(&ws, "invalid", .binary); + app.connection_manager.onMessage(&ws, "invalid", .binary); // Error level: error handling - manager.onClose(&ws); - } - - // Test 2: Verify log levels are consistent across components - // The codebase uses: - // - std.log.info for normal operations (connection open/close, startup) - // - std.log.warn for recoverable errors (parse failures, missing connections) - // - std.log.err for serious errors (processing failures, database errors) - { - // This is verified by code inspection and the fact that the code compiles - // and runs correctly with different log levels - try testing.expect(true); + app.connection_manager.onClose(&ws); } } @@ -414,153 +282,47 @@ test "logging: message formatting" { // Test 1: Verify log messages include required information { - var memory_strategy: MemoryStrategy = undefined; - try memory_strategy.init(testing.allocator); - defer std.debug.assert(memory_strategy.deinit() == .ok); - - var tracker: ViolationTracker = undefined; - tracker.init(allocator, 10); - defer tracker.deinit(); - - var context = try schema_helpers.TestContext.init(allocator, "logging-format"); - defer context.deinit(); - const test_dir = context.test_dir; - - var fields = [_]schema_types.Field{schema_helpers.makeField("val", .text)}; - var tables = try allocator.alloc(schema_types.Table, 1); - defer allocator.free(tables); - tables[0] = schema_helpers.makeTable("test", &fields); - var sm3 = try sth.createSchema(allocator, tables); - defer sm3.deinit(); - - var subscription_engine: SubscriptionEngine = SubscriptionEngine.init(allocator); - defer subscription_engine.deinit(); - - var storage_engine: StorageEngine = undefined; - try storage_engine.init(allocator, &memory_strategy, test_dir, &sm3, .{}, .{ .in_memory = true, .reader_pool_size = 1 }, null, null); - defer storage_engine.deinit(); - - var auth_config2 = try authorization_defaults.implicitConfig(allocator, &sm3); - defer auth_config2.deinit(); - - var store_service = StoreService.init(allocator, &storage_engine, &sm3, &auth_config2); - defer store_service.deinit(); - - var presence_service = PresenceService.init(allocator, null, &auth_config2, &sm3); - defer presence_service.deinit(); - - var handler: MessageHandler = undefined; - const empty_claims2: std.StringHashMapUnmanaged([]const u8) = .{}; - handler.init( - allocator, - &memory_strategy, - &tracker, - &store_service, - &presence_service, - &subscription_engine, - .{}, - &auth_config2, - &sm3, - null, - &empty_claims2, - ); - defer handler.deinit(); - - var manager: ConnectionManager = undefined; - try manager.init(allocator, &memory_strategy, &handler, &sm3, 100_000, 30); - defer manager.deinit(); + var app: AppTestContext = undefined; + try app.init(allocator, "logging-format", &.{ + .{ .name = "test", .fields = &.{"val"} }, + }); + defer app.deinit(); // Trigger various log messages - var ws = createMockWebSocket(memory_strategy.generalAllocator()); + var ws = createMockWebSocket(app.memory_strategy.generalAllocator()); // Connection logging includes connection ID - try manager.onOpen(&ws); + try app.connection_manager.onOpen(&ws); const conn_id = ws.getConnId(); try testing.expect(conn_id > 0); // Error logging includes error details - manager.onMessage(&ws, "invalid", .binary); + app.connection_manager.onMessage(&ws, "invalid", .binary); // Close logging includes connection ID and close code - manager.onClose(&ws); - } - - // Test 2: Verify log format consistency - { - // This is verified by code inspection - try testing.expect(true); + app.connection_manager.onClose(&ws); } // Test 3: Verify log messages are properly formatted with parameters { - var memory_strategy: MemoryStrategy = undefined; - try memory_strategy.init(allocator); - defer std.debug.assert(memory_strategy.deinit() == .ok); - - var tracker: ViolationTracker = undefined; - tracker.init(allocator, 10); - defer tracker.deinit(); - - var context = try schema_helpers.TestContext.init(allocator, "logging-params"); - defer context.deinit(); - const test_dir = context.test_dir; - - var fields = [_]schema_types.Field{schema_helpers.makeField("val", .text)}; - var tables = try allocator.alloc(schema_types.Table, 1); - defer allocator.free(tables); - tables[0] = schema_helpers.makeTable("test", &fields); - var sm4 = try sth.createSchema(allocator, tables); - defer sm4.deinit(); - - var subscription_engine = SubscriptionEngine.init(allocator); - defer subscription_engine.deinit(); - - var storage_engine: StorageEngine = undefined; - try storage_engine.init(allocator, &memory_strategy, test_dir, &sm4, .{}, .{ .in_memory = true, .reader_pool_size = 1 }, null, null); - defer storage_engine.deinit(); - - var auth_config3 = try authorization_defaults.implicitConfig(allocator, &sm4); - defer auth_config3.deinit(); - - var store_service = StoreService.init(allocator, &storage_engine, &sm4, &auth_config3); - defer store_service.deinit(); - - var presence_service = PresenceService.init(allocator, null, &auth_config3, &sm4); - defer presence_service.deinit(); - - var handler: MessageHandler = undefined; - const empty_claims3: std.StringHashMapUnmanaged([]const u8) = .{}; - handler.init( - allocator, - &memory_strategy, - &tracker, - &store_service, - &presence_service, - &subscription_engine, - .{}, - &auth_config3, - &sm4, - null, - &empty_claims3, - ); - defer handler.deinit(); - - var manager: ConnectionManager = undefined; - try manager.init(allocator, &memory_strategy, &handler, &sm4, 100_000, 30); - defer manager.deinit(); + var app: AppTestContext = undefined; + try app.init(allocator, "logging-params", &.{ + .{ .name = "test", .fields = &.{"val"} }, + }); + defer app.deinit(); // Test multiple connections to verify ID formatting const num_connections = 5; var connections: [num_connections]WebSocket = undefined; for (&connections) |*ws| { - ws.* = createMockWebSocket(memory_strategy.generalAllocator()); - try manager.onOpen(ws); + ws.* = createMockWebSocket(app.memory_strategy.generalAllocator()); + try app.connection_manager.onOpen(ws); } // Close all with different codes for (&connections) |*ws| { - manager.onClose(ws); + app.connection_manager.onClose(ws); } } } diff --git a/src/message_handler_test.zig b/src/message_handler_test.zig index 668c610f..f323f6f8 100644 --- a/src/message_handler_test.zig +++ b/src/message_handler_test.zig @@ -515,6 +515,7 @@ fn expectResponseType(allocator: std.mem.Allocator, response: []const u8, expect defer parsed.free(allocator); const value = (try msgpack_helpers.getMapValue(parsed, "type")) orelse return error.TestExpectedError; + try testing.expect(value == .str); try testing.expectEqualStrings(expected, value.str.value()); } @@ -532,9 +533,11 @@ fn expectErrorCode(allocator: std.mem.Allocator, response: []const u8, expected: defer parsed.free(allocator); const resp_type = (try msgpack_helpers.getMapValue(parsed, "type")) orelse return error.TestExpectedError; + try testing.expect(resp_type == .str); try testing.expectEqualStrings("error", resp_type.str.value()); const code = (try msgpack_helpers.getMapValue(parsed, "code")) orelse return error.TestExpectedError; + try testing.expect(code == .str); try testing.expectEqualStrings(expected, code.str.value()); } @@ -563,15 +566,13 @@ test "message: representative frames route at protocol boundary" { try expectResponseId(allocator, response, 11); } - blk: { + { const message = try store_helpers.createStoreQueryMessageWithEmptyFilter(allocator, 12, 1, table.index); defer allocator.free(message); - const response = (try routeBytesOptional(&app, sc.conn, allocator, message)) orelse break :blk; - defer allocator.free(response); - - try expectResponseType(allocator, response, "ok"); - try expectResponseId(allocator, response, 12); + // StoreQuery is not yet implemented: no response is produced. + const response = try routeBytesOptional(&app, sc.conn, allocator, message); + try testing.expect(response == null); } { @@ -609,13 +610,13 @@ test "message: response id is preserved across routed requests" { try expectResponseId(allocator, response, 101); } - blk: { + { const message = try store_helpers.createStoreQueryMessageWithEmptyFilter(allocator, 202, 1, table.index); defer allocator.free(message); - const response = (try routeBytesOptional(&app, sc.conn, allocator, message)) orelse break :blk; - defer allocator.free(response); - try expectResponseId(allocator, response, 202); + // StoreQuery is not yet implemented: no response is produced. + const response = try routeBytesOptional(&app, sc.conn, allocator, message); + try testing.expect(response == null); } { diff --git a/src/migration_detector_test.zig b/src/migration_detector_test.zig index 19a2751b..5e1518ac 100644 --- a/src/migration_detector_test.zig +++ b/src/migration_detector_test.zig @@ -42,163 +42,161 @@ fn execSchemaDDL(db: *sqlite.Db, allocator: std.mem.Allocator, gen: *ddl_generat test "migration_detector: migration plan accurately describes schema diff" { const allocator = std.testing.allocator; - var prng = std.Random.DefaultPrng.init(42); - const rand = prng.random(); - const table_names = [_][]const u8{ "posts", "items", "orders", "tags", "comments" }; - const field_names = [_][]const u8{ "title", "status", "count", "active", "score" }; - - var iter: usize = 0; - while (iter < 100) : (iter += 1) { - const tname = table_names[rand.intRangeAtMost(usize, 0, table_names.len - 1)]; - const scenario = rand.intRangeAtMost(u8, 0, 3); - - var db = try openMemDb(); - defer db.deinit(); - - var gen = ddl_generator.DDLGenerator.init(allocator); - - switch (scenario) { - 0 => { - // Table doesn't exist in DB → expect create_table - var target_fields = [_]schema_types.Field{schema_helpers.makeField("title", .text)}; - var target_tables = [_]schema_types.Table{try schema_helpers.makeTableAlloc(allocator, tname, &target_fields)}; - defer { - allocator.free(target_tables[0].name); - allocator.free(target_tables[0].name_quoted); - } - var target_schema = try schema_helpers.initSchemaFromTables(allocator, "1.0.0", &target_tables); - defer target_schema.deinit(); - const users_table = target_schema.table("users") orelse return error.TestExpectedValue; - try execTableDDL(&db, allocator, &gen, users_table.*); - - var detector = MigrationDetector.init(allocator, &db, &target_schema); - const plan = try detector.detectChanges(&target_schema); - defer detector.deinit(plan); - - try std.testing.expectEqual(@as(usize, 1), plan.changes.len); - try std.testing.expectEqual(ChangeKind.create_table, plan.changes[0].kind); - try std.testing.expectEqualStrings(tname, plan.changes[0].table.name); - try std.testing.expect(plan.changes[0].field == null); - try std.testing.expect(!plan.is_destructive); - }, - 1 => { - // Table exists with one column; target adds a new column → expect add_column - var existing_fields = [_]schema_types.Field{schema_helpers.makeField("title", .text)}; - const existing_tables = [_]schema_types.Table{try schema_helpers.makeTableAlloc(allocator, tname, &existing_fields)}; - defer { - allocator.free(existing_tables[0].name); - allocator.free(existing_tables[0].name_quoted); - } - const existing_ddl = try gen.generateDDL(existing_tables[0]); - defer allocator.free(existing_ddl); - try execDDL(&db, allocator, existing_ddl); - - var existing_schema = try schema_helpers.initSchemaFromTables(allocator, "1.0.0", &existing_tables); - defer existing_schema.deinit(); - const users_table = existing_schema.table("users") orelse return error.TestExpectedValue; - try execTableDDL(&db, allocator, &gen, users_table.*); - - const new_fname = field_names[rand.intRangeAtMost(usize, 1, field_names.len - 1)]; - const new_field = try schema_helpers.makeFieldAlloc(allocator, new_fname, .integer); - var target_fields = [_]schema_types.Field{ - schema_helpers.makeField("title", .text), - new_field, - }; - var target_tables = [_]schema_types.Table{try schema_helpers.makeTableAlloc(allocator, tname, &target_fields)}; - defer { - allocator.free(target_tables[0].name); - allocator.free(target_tables[0].name_quoted); - allocator.free(new_field.name); - allocator.free(new_field.name_quoted); - } - var target_schema = try schema_helpers.initSchemaFromTables(allocator, "1.0.0", &target_tables); - defer target_schema.deinit(); - - var detector = MigrationDetector.init(allocator, &db, &existing_schema); - const plan = try detector.detectChanges(&target_schema); - defer detector.deinit(plan); - - try std.testing.expectEqual(@as(usize, 1), plan.changes.len); - try std.testing.expectEqual(ChangeKind.add_column, plan.changes[0].kind); - try std.testing.expectEqualStrings(tname, plan.changes[0].table.name); - try std.testing.expect(plan.changes[0].field != null); - try std.testing.expectEqualStrings(new_fname, plan.changes[0].field.?.name); - try std.testing.expect(!plan.is_destructive); - }, - 2 => { - // Table exists with TEXT column; target changes it to INTEGER → expect change_type - var existing_fields = [_]schema_types.Field{schema_helpers.makeField("status", .text)}; - const existing_tables = [_]schema_types.Table{try schema_helpers.makeTableAlloc(allocator, tname, &existing_fields)}; - defer { - allocator.free(existing_tables[0].name); - allocator.free(existing_tables[0].name_quoted); - } - const existing_ddl = try gen.generateDDL(existing_tables[0]); - defer allocator.free(existing_ddl); - try execDDL(&db, allocator, existing_ddl); - - var existing_schema = try schema_helpers.initSchemaFromTables(allocator, "1.0.0", &existing_tables); - defer existing_schema.deinit(); - const users_table = existing_schema.table("users") orelse return error.TestExpectedValue; - try execTableDDL(&db, allocator, &gen, users_table.*); - - var target_fields = [_]schema_types.Field{schema_helpers.makeField("status", .integer)}; - var target_tables = [_]schema_types.Table{try schema_helpers.makeTableAlloc(allocator, tname, &target_fields)}; - defer { - allocator.free(target_tables[0].name); - allocator.free(target_tables[0].name_quoted); - } - var target_schema = try schema_helpers.initSchemaFromTables(allocator, "1.0.0", &target_tables); - defer target_schema.deinit(); - - var detector = MigrationDetector.init(allocator, &db, &existing_schema); - const plan = try detector.detectChanges(&target_schema); - defer detector.deinit(plan); - - try std.testing.expectEqual(@as(usize, 1), plan.changes.len); - try std.testing.expectEqual(ChangeKind.change_type, plan.changes[0].kind); - try std.testing.expectEqualStrings(tname, plan.changes[0].table.name); - try std.testing.expect(plan.is_destructive); - }, - else => { - // Table exists with extra column not in target → expect remove_column - var existing_fields = [_]schema_types.Field{ - schema_helpers.makeField("title", .text), - schema_helpers.makeField("extra_col", .text), - }; - const existing_tables = [_]schema_types.Table{try schema_helpers.makeTableAlloc(allocator, tname, &existing_fields)}; - defer { - allocator.free(existing_tables[0].name); - allocator.free(existing_tables[0].name_quoted); - } - const existing_ddl = try gen.generateDDL(existing_tables[0]); - defer allocator.free(existing_ddl); - try execDDL(&db, allocator, existing_ddl); - - var existing_schema = try schema_helpers.initSchemaFromTables(allocator, "1.0.0", &existing_tables); - defer existing_schema.deinit(); - const users_table = existing_schema.table("users") orelse return error.TestExpectedValue; - try execTableDDL(&db, allocator, &gen, users_table.*); - - var target_fields = [_]schema_types.Field{schema_helpers.makeField("title", .text)}; - var target_tables = [_]schema_types.Table{try schema_helpers.makeTableAlloc(allocator, tname, &target_fields)}; - defer { - allocator.free(target_tables[0].name); - allocator.free(target_tables[0].name_quoted); - } - var target_schema = try schema_helpers.initSchemaFromTables(allocator, "1.0.0", &target_tables); - defer target_schema.deinit(); - - var detector = MigrationDetector.init(allocator, &db, &existing_schema); - const plan = try detector.detectChanges(&target_schema); - defer detector.deinit(plan); - - try std.testing.expectEqual(@as(usize, 1), plan.changes.len); - try std.testing.expectEqual(ChangeKind.remove_column, plan.changes[0].kind); - try std.testing.expectEqualStrings(tname, plan.changes[0].table.name); - try std.testing.expect(plan.is_destructive); - }, + + // Exhaustive over all table names x all four diff scenarios. The implicit + // "users" table matches in every scenario, so exactly one change is expected. + for (table_names) |tname| { + for (0..4) |scenario_raw| { + const scenario: u8 = @intCast(scenario_raw); + + var db = try openMemDb(); + defer db.deinit(); + + var gen = ddl_generator.DDLGenerator.init(allocator); + + switch (scenario) { + 0 => { + // Table doesn't exist in DB → expect create_table + var target_fields = [_]schema_types.Field{schema_helpers.makeField("title", .text)}; + var target_tables = [_]schema_types.Table{try schema_helpers.makeTableAlloc(allocator, tname, &target_fields)}; + defer { + allocator.free(target_tables[0].name); + allocator.free(target_tables[0].name_quoted); + } + var target_schema = try schema_helpers.initSchemaFromTables(allocator, "1.0.0", &target_tables); + defer target_schema.deinit(); + const users_table = target_schema.table("users") orelse return error.TestExpectedValue; + try execTableDDL(&db, allocator, &gen, users_table.*); + + var detector = MigrationDetector.init(allocator, &db, &target_schema); + const plan = try detector.detectChanges(&target_schema); + defer detector.deinit(plan); + + try std.testing.expectEqual(@as(usize, 1), plan.changes.len); + try std.testing.expectEqual(ChangeKind.create_table, plan.changes[0].kind); + try std.testing.expectEqualStrings(tname, plan.changes[0].table.name); + try std.testing.expect(plan.changes[0].field == null); + try std.testing.expect(!plan.is_destructive); + }, + 1 => { + // Table exists with one column; target adds a new column → expect add_column + var existing_fields = [_]schema_types.Field{schema_helpers.makeField("title", .text)}; + const existing_tables = [_]schema_types.Table{try schema_helpers.makeTableAlloc(allocator, tname, &existing_fields)}; + defer { + allocator.free(existing_tables[0].name); + allocator.free(existing_tables[0].name_quoted); + } + const existing_ddl = try gen.generateDDL(existing_tables[0]); + defer allocator.free(existing_ddl); + try execDDL(&db, allocator, existing_ddl); + + var existing_schema = try schema_helpers.initSchemaFromTables(allocator, "1.0.0", &existing_tables); + defer existing_schema.deinit(); + const users_table = existing_schema.table("users") orelse return error.TestExpectedValue; + try execTableDDL(&db, allocator, &gen, users_table.*); + + const new_fname = "count"; + const new_field = try schema_helpers.makeFieldAlloc(allocator, new_fname, .integer); + var target_fields = [_]schema_types.Field{ + schema_helpers.makeField("title", .text), + new_field, + }; + var target_tables = [_]schema_types.Table{try schema_helpers.makeTableAlloc(allocator, tname, &target_fields)}; + defer { + allocator.free(target_tables[0].name); + allocator.free(target_tables[0].name_quoted); + allocator.free(new_field.name); + allocator.free(new_field.name_quoted); + } + var target_schema = try schema_helpers.initSchemaFromTables(allocator, "1.0.0", &target_tables); + defer target_schema.deinit(); + + var detector = MigrationDetector.init(allocator, &db, &existing_schema); + const plan = try detector.detectChanges(&target_schema); + defer detector.deinit(plan); + + try std.testing.expectEqual(@as(usize, 1), plan.changes.len); + try std.testing.expectEqual(ChangeKind.add_column, plan.changes[0].kind); + try std.testing.expectEqualStrings(tname, plan.changes[0].table.name); + try std.testing.expect(plan.changes[0].field != null); + try std.testing.expectEqualStrings(new_fname, plan.changes[0].field.?.name); + try std.testing.expect(!plan.is_destructive); + }, + 2 => { + // Table exists with TEXT column; target changes it to INTEGER → expect change_type + var existing_fields = [_]schema_types.Field{schema_helpers.makeField("status", .text)}; + const existing_tables = [_]schema_types.Table{try schema_helpers.makeTableAlloc(allocator, tname, &existing_fields)}; + defer { + allocator.free(existing_tables[0].name); + allocator.free(existing_tables[0].name_quoted); + } + const existing_ddl = try gen.generateDDL(existing_tables[0]); + defer allocator.free(existing_ddl); + try execDDL(&db, allocator, existing_ddl); + + var existing_schema = try schema_helpers.initSchemaFromTables(allocator, "1.0.0", &existing_tables); + defer existing_schema.deinit(); + const users_table = existing_schema.table("users") orelse return error.TestExpectedValue; + try execTableDDL(&db, allocator, &gen, users_table.*); + + var target_fields = [_]schema_types.Field{schema_helpers.makeField("status", .integer)}; + var target_tables = [_]schema_types.Table{try schema_helpers.makeTableAlloc(allocator, tname, &target_fields)}; + defer { + allocator.free(target_tables[0].name); + allocator.free(target_tables[0].name_quoted); + } + var target_schema = try schema_helpers.initSchemaFromTables(allocator, "1.0.0", &target_tables); + defer target_schema.deinit(); + + var detector = MigrationDetector.init(allocator, &db, &existing_schema); + const plan = try detector.detectChanges(&target_schema); + defer detector.deinit(plan); + + try std.testing.expectEqual(@as(usize, 1), plan.changes.len); + try std.testing.expectEqual(ChangeKind.change_type, plan.changes[0].kind); + try std.testing.expectEqualStrings(tname, plan.changes[0].table.name); + try std.testing.expect(plan.is_destructive); + }, + else => { + // Table exists with extra column not in target → expect remove_column + var existing_fields = [_]schema_types.Field{ + schema_helpers.makeField("title", .text), + schema_helpers.makeField("extra_col", .text), + }; + const existing_tables = [_]schema_types.Table{try schema_helpers.makeTableAlloc(allocator, tname, &existing_fields)}; + defer { + allocator.free(existing_tables[0].name); + allocator.free(existing_tables[0].name_quoted); + } + const existing_ddl = try gen.generateDDL(existing_tables[0]); + defer allocator.free(existing_ddl); + try execDDL(&db, allocator, existing_ddl); + + var existing_schema = try schema_helpers.initSchemaFromTables(allocator, "1.0.0", &existing_tables); + defer existing_schema.deinit(); + const users_table = existing_schema.table("users") orelse return error.TestExpectedValue; + try execTableDDL(&db, allocator, &gen, users_table.*); + + var target_fields = [_]schema_types.Field{schema_helpers.makeField("title", .text)}; + var target_tables = [_]schema_types.Table{try schema_helpers.makeTableAlloc(allocator, tname, &target_fields)}; + defer { + allocator.free(target_tables[0].name); + allocator.free(target_tables[0].name_quoted); + } + var target_schema = try schema_helpers.initSchemaFromTables(allocator, "1.0.0", &target_tables); + defer target_schema.deinit(); + + var detector = MigrationDetector.init(allocator, &db, &existing_schema); + const plan = try detector.detectChanges(&target_schema); + defer detector.deinit(plan); + + try std.testing.expectEqual(@as(usize, 1), plan.changes.len); + try std.testing.expectEqual(ChangeKind.remove_column, plan.changes[0].kind); + try std.testing.expectEqualStrings(tname, plan.changes[0].table.name); + try std.testing.expect(plan.is_destructive); + }, + } } } } @@ -209,64 +207,71 @@ test "migration_detector: migration plan accurately describes schema diff" { test "migration_detector: matching schema produces empty migration plan" { const allocator = std.testing.allocator; - var prng = std.Random.DefaultPrng.init(99); - const rand = prng.random(); - const table_names = [_][]const u8{ "alpha", "beta", "gamma", "delta", "epsilon" }; const field_names = [_][]const u8{ "col_a", "col_b", "col_c", "col_d" }; const field_types = [_]schema_types.FieldType{ .text, .integer, .real }; - var iter: usize = 0; - while (iter < 100) : (iter += 1) { - var db = try openMemDb(); - defer db.deinit(); - - var gen = ddl_generator.DDLGenerator.init(allocator); - - const n_tables = rand.intRangeAtMost(usize, 1, 3); - const tables = try allocator.alloc(schema_types.Table, n_tables); - defer { - for (tables) |t| { - allocator.free(t.name); - allocator.free(t.name_quoted); - for (t.fields) |f| { - allocator.free(f.name); - allocator.free(f.name_quoted); + // Exhaustive over table counts (1..3) and name/type offsets so every + // combination of field count, name, and type is exercised. + for (1..4) |n_tables| { + for (0..4) |offset| { + var db = try openMemDb(); + defer db.deinit(); + + var gen = ddl_generator.DDLGenerator.init(allocator); + + const tables = try allocator.alloc(schema_types.Table, n_tables); + var tables_ok = false; + defer { + if (tables_ok) { + for (tables) |t| { + allocator.free(t.name); + allocator.free(t.name_quoted); + for (t.fields) |f| { + allocator.free(f.name); + allocator.free(f.name_quoted); + } + allocator.free(t.fields); + } } - allocator.free(t.fields); + allocator.free(tables); } - allocator.free(tables); - } - for (0..n_tables) |ti| { - const tname = table_names[ti % table_names.len]; - const n_fields = rand.intRangeAtMost(usize, 1, 3); - const fields = try allocator.alloc(schema_types.Field, n_fields); + for (0..n_tables) |ti| { + const tname = table_names[(ti + offset) % table_names.len]; + const n_fields = 1 + ((ti + offset) % 3); + const fields = try allocator.alloc(schema_types.Field, n_fields); + var filled: usize = 0; + errdefer { + for (fields[0..filled]) |f| { + allocator.free(f.name); + allocator.free(f.name_quoted); + } + allocator.free(fields); + } - for (0..n_fields) |fi| { - const mf_name = field_names[fi % field_names.len]; - const mf_type = field_types[rand.intRangeAtMost(usize, 0, field_types.len - 1)]; - fields[fi] = try schema_helpers.makeFieldAlloc(allocator, mf_name, mf_type); - } + for (0..n_fields) |fi| { + const mf_name = field_names[fi % field_names.len]; + const mf_type = field_types[(fi + offset) % field_types.len]; + fields[fi] = try schema_helpers.makeFieldAlloc(allocator, mf_name, mf_type); + filled += 1; + } - tables[ti] = try schema_helpers.makeTableAlloc(allocator, tname, fields); - } + // makeTableAlloc takes ownership of `fields` (freed via tables_ok above). + tables[ti] = try schema_helpers.makeTableAlloc(allocator, tname, fields); + } + tables_ok = true; - var target_schema = try schema_helpers.initSchemaFromTables(allocator, "1.0.0", tables); - defer target_schema.deinit(); - try execSchemaDDL(&db, allocator, &gen, &target_schema); + var target_schema = try schema_helpers.initSchemaFromTables(allocator, "1.0.0", tables); + defer target_schema.deinit(); + try execSchemaDDL(&db, allocator, &gen, &target_schema); - var detector = MigrationDetector.init(allocator, &db, &target_schema); - const plan = try detector.detectChanges(&target_schema); - defer detector.deinit(plan); + var detector = MigrationDetector.init(allocator, &db, &target_schema); + const plan = try detector.detectChanges(&target_schema); + defer detector.deinit(plan); - if (plan.changes.len != 0) { - std.debug.print("iter {d}: expected 0 changes, got {d}\n", .{ iter, plan.changes.len }); - for (plan.changes) |c| { - std.debug.print(" change: kind={s} table={s}\n", .{ @tagName(c.kind), c.table.name }); - } + try std.testing.expectEqual(@as(usize, 0), plan.changes.len); + try std.testing.expect(!plan.is_destructive); } - try std.testing.expectEqual(@as(usize, 0), plan.changes.len); - try std.testing.expect(!plan.is_destructive); } } diff --git a/src/migration_executor_test.zig b/src/migration_executor_test.zig index 68583a56..16a55c25 100644 --- a/src/migration_executor_test.zig +++ b/src/migration_executor_test.zig @@ -227,18 +227,13 @@ fn insertSchemaMetaVersion(db: *sqlite.Db, allocator: std.mem.Allocator, version test "migration_executor: additive migration preserves existing data" { const allocator = std.testing.allocator; - var prng = std.Random.DefaultPrng.init(42); - const rand = prng.random(); - const table_names = [_][]const u8{ "posts", "items", "orders", "tags", "comments" }; - var iter: usize = 0; - while (iter < 100) : (iter += 1) { + for (table_names) |tname| { var db = try openMemDb(); defer db.deinit(); var gen = ddl_generator.DDLGenerator.init(allocator); - const tname = table_names[rand.intRangeAtMost(usize, 0, table_names.len - 1)]; // Create table with initial columns var initial_table = schema_helpers.makeSingleRuntimeTable(allocator, tname, &[_]schema_helpers.TestFieldDef{ @@ -326,87 +321,116 @@ test "migration_executor: additive migration preserves existing data" { test "migration_executor: destructive migration refused when not allowed" { const allocator = std.testing.allocator; - var prng = std.Random.DefaultPrng.init(77); - const rand = prng.random(); - const table_names = [_][]const u8{ "alpha", "beta", "gamma", "delta", "epsilon" }; const destructive_kinds = [_]migration_detector.ChangeKind{ .change_type, .remove_column }; - var iter: usize = 0; - while (iter < 100) : (iter += 1) { - var db = try openMemDb(); - defer db.deinit(); - - var gen = ddl_generator.DDLGenerator.init(allocator); - const tname = table_names[rand.intRangeAtMost(usize, 0, table_names.len - 1)]; - - // Create table - var table = schema_helpers.makeSingleRuntimeTable(allocator, tname, &[_]schema_helpers.TestFieldDef{ - .{ .name = "col_a", .field_type = .text }, - }); - defer table.deinit(allocator); - const ddl = try gen.generateDDL(table); - defer allocator.free(ddl); - try execMultiSql(&db, allocator, ddl); - - // Insert a row to verify DB is unchanged after refused migration - const insert_sql = try std.fmt.allocPrint( - allocator, - "INSERT INTO {s} (id, namespace_id, owner_id, col_a, created_at, updated_at) VALUES (zeroblob(16), 1, zeroblob(16), 'val1', 0, 0)", - .{tname}, - ); - defer allocator.free(insert_sql); - try execSql(&db, allocator, insert_sql); - - // Build destructive plan - const kind = destructive_kinds[rand.intRangeAtMost(usize, 0, destructive_kinds.len - 1)]; - - var target_table = schema_helpers.makeSingleRuntimeTable(allocator, tname, &[_]schema_helpers.TestFieldDef{ - .{ .name = "col_a", .field_type = .integer }, - }); - defer target_table.deinit(allocator); - const target_version = "1.0.0"; - - const changes = try allocator.alloc(migration_detector.Change, 1); - defer allocator.free(changes); - changes[0] = .{ - .kind = kind, - .table = &target_table, - .field = schema_helpers.makeField("col_a", .integer), - }; - - const plan = migration_detector.MigrationPlan{ - .changes = changes, - .is_destructive = true, - }; - - var executor = MigrationExecutor.init(allocator, &db, &gen, .{ - .auto_migrate = .additive_only, - .allow_destructive = false, - }); - - const result = executor.execute(plan, target_version); - try std.testing.expectError(error.DestructiveMigrationNotAllowed, result); - - // Verify DB is unchanged: original row still exists - const check_sql = try std.fmt.allocPrint( - allocator, - "SELECT id FROM {s} WHERE id = zeroblob(16)", - .{tname}, - ); - defer allocator.free(check_sql); - const check_sql_z = try allocator.dupeZ(u8, check_sql); - defer allocator.free(check_sql_z); - - var stmt = try db.prepareDynamic(check_sql_z); - defer stmt.deinit(); - - const IdRow = struct { id: []const u8 }; - var check_iter = try stmt.iteratorAlloc(IdRow, allocator, .{}); - const row = try check_iter.nextAlloc(allocator, .{}); - try std.testing.expect(row != null); - defer allocator.free(row.?.id); - try std.testing.expectEqualSlices(u8, &zero_doc_id, row.?.id); + for (table_names) |tname| { + for (destructive_kinds) |kind| { + var db = try openMemDb(); + defer db.deinit(); + + var gen = ddl_generator.DDLGenerator.init(allocator); + + // Create table + var table = schema_helpers.makeSingleRuntimeTable(allocator, tname, &[_]schema_helpers.TestFieldDef{ + .{ .name = "col_a", .field_type = .text }, + }); + defer table.deinit(allocator); + const ddl = try gen.generateDDL(table); + defer allocator.free(ddl); + try execMultiSql(&db, allocator, ddl); + + // Insert a row to verify DB is unchanged after refused migration + const insert_sql = try std.fmt.allocPrint( + allocator, + "INSERT INTO {s} (id, namespace_id, owner_id, col_a, created_at, updated_at) VALUES (zeroblob(16), 1, zeroblob(16), 'val1', 0, 0)", + .{tname}, + ); + defer allocator.free(insert_sql); + try execSql(&db, allocator, insert_sql); + + // Build destructive plan + var target_table = schema_helpers.makeSingleRuntimeTable(allocator, tname, &[_]schema_helpers.TestFieldDef{ + .{ .name = "col_a", .field_type = .integer }, + }); + defer target_table.deinit(allocator); + const target_version = "1.0.0"; + + const changes = try allocator.alloc(migration_detector.Change, 1); + defer allocator.free(changes); + changes[0] = .{ + .kind = kind, + .table = &target_table, + .field = schema_helpers.makeField("col_a", .integer), + }; + + const plan = migration_detector.MigrationPlan{ + .changes = changes, + .is_destructive = true, + }; + + var executor = MigrationExecutor.init(allocator, &db, &gen, .{ + .auto_migrate = .additive_only, + .allow_destructive = false, + }); + + const result = executor.execute(plan, target_version); + try std.testing.expectError(error.DestructiveMigrationNotAllowed, result); + + // Verify DB is unchanged: original row still exists + const check_sql = try std.fmt.allocPrint( + allocator, + "SELECT id FROM {s} WHERE id = zeroblob(16)", + .{tname}, + ); + defer allocator.free(check_sql); + const check_sql_z = try allocator.dupeZ(u8, check_sql); + defer allocator.free(check_sql_z); + + var stmt = try db.prepareDynamic(check_sql_z); + defer stmt.deinit(); + + const IdRow = struct { id: []const u8 }; + var check_iter = try stmt.iteratorAlloc(IdRow, allocator, .{}); + const row = try check_iter.nextAlloc(allocator, .{}); + try std.testing.expect(row != null); + defer allocator.free(row.?.id); + try std.testing.expectEqualSlices(u8, &zero_doc_id, row.?.id); + + // Verify DB schema is unchanged: col_a must still be TEXT + const pragma_sql = try std.fmt.allocPrint( + allocator, + "PRAGMA table_info({s})", + .{tname}, + ); + defer allocator.free(pragma_sql); + + var pragma_stmt = try db.prepareDynamic(pragma_sql); + defer pragma_stmt.deinit(); + + const PragmaRow = struct { + cid: i64, + name: []const u8, + type: []const u8, + notnull: i64, + dflt_value: ?[]const u8, + pk: i64, + }; + + var col_a_is_text = false; + var pragma_iter = try pragma_stmt.iteratorAlloc(PragmaRow, allocator, .{}); + while (try pragma_iter.nextAlloc(allocator, .{})) |pragma_row| { + defer { + allocator.free(pragma_row.name); + allocator.free(pragma_row.type); + if (pragma_row.dflt_value) |dv| allocator.free(dv); + } + if (std.mem.eql(u8, pragma_row.name, "col_a") and std.mem.eql(u8, pragma_row.type, "TEXT")) { + col_a_is_text = true; + } + } + try std.testing.expect(col_a_is_text); + } } } @@ -417,58 +441,54 @@ test "migration_executor: destructive migration refused when not allowed" { test "migration_executor: schema version persisted after migration" { const allocator = std.testing.allocator; - var prng = std.Random.DefaultPrng.init(55); - const rand = prng.random(); - const table_names = [_][]const u8{ "things", "stuff", "items", "records", "entries" }; const versions = [_][]const u8{ "1.0.0", "1.1.0", "1.2.3", "2.0.0", "0.1.0" }; - var iter: usize = 0; - while (iter < 100) : (iter += 1) { - var db = try openMemDb(); - defer db.deinit(); - - var gen = ddl_generator.DDLGenerator.init(allocator); - const tname = table_names[rand.intRangeAtMost(usize, 0, table_names.len - 1)]; - const version = versions[rand.intRangeAtMost(usize, 0, versions.len - 1)]; - - // Build a create_table plan - var target_table = schema_helpers.makeSingleRuntimeTable(allocator, tname, &[_]schema_helpers.TestFieldDef{ - .{ .name = "name", .field_type = .text }, - }); - defer target_table.deinit(allocator); - const target_version = version; - - const changes = try allocator.alloc(migration_detector.Change, 1); - defer allocator.free(changes); - changes[0] = .{ - .kind = .create_table, - .table = &target_table, - .field = null, - }; - - const plan = migration_detector.MigrationPlan{ - .changes = changes, - .is_destructive = false, - }; - - var executor = MigrationExecutor.init(allocator, &db, &gen, .{ - .auto_migrate = .full, - .allow_destructive = false, - }); - - try executor.execute(plan, target_version); - - // Verify schema_meta has the correct version - var stmt = try db.prepare("SELECT version FROM schema_meta LIMIT 1"); - defer stmt.deinit(); - - const VersionRow = struct { version: []const u8 }; - var ver_iter = try stmt.iteratorAlloc(VersionRow, allocator, .{}); - const ver_row = try ver_iter.nextAlloc(allocator, .{}); - try std.testing.expect(ver_row != null); - defer allocator.free(ver_row.?.version); - try std.testing.expectEqualStrings(version, ver_row.?.version); + for (table_names) |tname| { + for (versions) |version| { + var db = try openMemDb(); + defer db.deinit(); + + var gen = ddl_generator.DDLGenerator.init(allocator); + + // Build a create_table plan + var target_table = schema_helpers.makeSingleRuntimeTable(allocator, tname, &[_]schema_helpers.TestFieldDef{ + .{ .name = "name", .field_type = .text }, + }); + defer target_table.deinit(allocator); + const target_version = version; + + const changes = try allocator.alloc(migration_detector.Change, 1); + defer allocator.free(changes); + changes[0] = .{ + .kind = .create_table, + .table = &target_table, + .field = null, + }; + + const plan = migration_detector.MigrationPlan{ + .changes = changes, + .is_destructive = false, + }; + + var executor = MigrationExecutor.init(allocator, &db, &gen, .{ + .auto_migrate = .full, + .allow_destructive = false, + }); + + try executor.execute(plan, target_version); + + // Verify schema_meta has the correct version + var stmt = try db.prepare("SELECT version FROM schema_meta LIMIT 1"); + defer stmt.deinit(); + + const VersionRow = struct { version: []const u8 }; + var ver_iter = try stmt.iteratorAlloc(VersionRow, allocator, .{}); + const ver_row = try ver_iter.nextAlloc(allocator, .{}); + try std.testing.expect(ver_row != null); + defer allocator.free(ver_row.?.version); + try std.testing.expectEqualStrings(version, ver_row.?.version); + } } } @@ -479,101 +499,97 @@ test "migration_executor: schema version persisted after migration" { test "migration_executor: major version bump is refused" { const allocator = std.testing.allocator; - var prng = std.Random.DefaultPrng.init(33); - const rand = prng.random(); - const table_names = [_][]const u8{ "docs", "notes", "files", "blobs", "chunks" }; - var iter: usize = 0; - while (iter < 100) : (iter += 1) { - var db = try openMemDb(); - defer db.deinit(); - - var gen = ddl_generator.DDLGenerator.init(allocator); - const tname = table_names[rand.intRangeAtMost(usize, 0, table_names.len - 1)]; - - // Create table and insert persisted version with lower major - var table = schema_helpers.makeSingleRuntimeTable(allocator, tname, &[_]schema_helpers.TestFieldDef{ - .{ .name = "data", .field_type = .text }, - }); - defer table.deinit(allocator); - const ddl = try gen.generateDDL(table); - defer allocator.free(ddl); - try execMultiSql(&db, allocator, ddl); - - // Persisted version: major = 1 - const persisted_major = rand.intRangeAtMost(u32, 1, 5); - const target_major = persisted_major + 1 + rand.intRangeAtMost(u32, 0, 3); - - const persisted_ver = try std.fmt.allocPrint(allocator, "{d}.0.0", .{persisted_major}); - defer allocator.free(persisted_ver); - const target_ver = try std.fmt.allocPrint(allocator, "{d}.0.0", .{target_major}); - defer allocator.free(target_ver); - - try insertSchemaMetaVersion(&db, allocator, persisted_ver); - - // Build a simple add_column plan - var target_table = schema_helpers.makeSingleRuntimeTable(allocator, tname, &[_]schema_helpers.TestFieldDef{ - .{ .name = "data", .field_type = .text }, - .{ .name = "extra", .field_type = .text }, - }); - defer target_table.deinit(allocator); - const target_version = target_ver; - - const changes = try allocator.alloc(migration_detector.Change, 1); - defer allocator.free(changes); - changes[0] = .{ - .kind = .add_column, - .table = &target_table, - .field = schema_helpers.makeField("extra", .text), - }; - - const plan = migration_detector.MigrationPlan{ - .changes = changes, - .is_destructive = false, - }; - - var executor = MigrationExecutor.init(allocator, &db, &gen, .{ - .auto_migrate = .full, - .allow_destructive = false, - }); - - const result = executor.execute(plan, target_version); - try std.testing.expectError(error.MajorVersionBumpNotAllowed, result); - - // Verify no changes were applied: column 'extra' should not exist - // Check via PRAGMA table_info - 'extra' column should not be present - const pragma_sql = try std.fmt.allocPrint( - allocator, - "PRAGMA table_info({s})", - .{tname}, - ); - defer allocator.free(pragma_sql); - - var pragma_stmt = try db.prepareDynamic(pragma_sql); - defer pragma_stmt.deinit(); - - const PragmaRow = struct { - cid: i64, - name: []const u8, - type: []const u8, - notnull: i64, - dflt_value: ?[]const u8, - pk: i64, - }; - - var found_extra = false; - var pragma_iter = try pragma_stmt.iteratorAlloc(PragmaRow, allocator, .{}); - while (try pragma_iter.nextAlloc(allocator, .{})) |row| { - defer { - allocator.free(row.name); - allocator.free(row.type); - if (row.dflt_value) |dv| allocator.free(dv); - } - if (std.mem.eql(u8, row.name, "extra")) { - found_extra = true; + for (table_names) |tname| { + for (1..4) |persisted_major| { + var db = try openMemDb(); + defer db.deinit(); + + var gen = ddl_generator.DDLGenerator.init(allocator); + + // Create table and insert persisted version with lower major + var table = schema_helpers.makeSingleRuntimeTable(allocator, tname, &[_]schema_helpers.TestFieldDef{ + .{ .name = "data", .field_type = .text }, + }); + defer table.deinit(allocator); + const ddl = try gen.generateDDL(table); + defer allocator.free(ddl); + try execMultiSql(&db, allocator, ddl); + + // Persisted version: major = 1 + const target_major = persisted_major + 1; + + const persisted_ver = try std.fmt.allocPrint(allocator, "{d}.0.0", .{persisted_major}); + defer allocator.free(persisted_ver); + const target_ver = try std.fmt.allocPrint(allocator, "{d}.0.0", .{target_major}); + defer allocator.free(target_ver); + + try insertSchemaMetaVersion(&db, allocator, persisted_ver); + + // Build a simple add_column plan + var target_table = schema_helpers.makeSingleRuntimeTable(allocator, tname, &[_]schema_helpers.TestFieldDef{ + .{ .name = "data", .field_type = .text }, + .{ .name = "extra", .field_type = .text }, + }); + defer target_table.deinit(allocator); + const target_version = target_ver; + + const changes = try allocator.alloc(migration_detector.Change, 1); + defer allocator.free(changes); + changes[0] = .{ + .kind = .add_column, + .table = &target_table, + .field = schema_helpers.makeField("extra", .text), + }; + + const plan = migration_detector.MigrationPlan{ + .changes = changes, + .is_destructive = false, + }; + + var executor = MigrationExecutor.init(allocator, &db, &gen, .{ + .auto_migrate = .full, + .allow_destructive = false, + }); + + const result = executor.execute(plan, target_version); + try std.testing.expectError(error.MajorVersionBumpNotAllowed, result); + + // Verify no changes were applied: column 'extra' should not exist + // Check via PRAGMA table_info - 'extra' column should not be present + const pragma_sql = try std.fmt.allocPrint( + allocator, + "PRAGMA table_info({s})", + .{tname}, + ); + defer allocator.free(pragma_sql); + + var pragma_stmt = try db.prepareDynamic(pragma_sql); + defer pragma_stmt.deinit(); + + const PragmaRow = struct { + cid: i64, + name: []const u8, + type: []const u8, + notnull: i64, + dflt_value: ?[]const u8, + pk: i64, + }; + + var found_extra = false; + var pragma_iter = try pragma_stmt.iteratorAlloc(PragmaRow, allocator, .{}); + while (try pragma_iter.nextAlloc(allocator, .{})) |row| { + defer { + allocator.free(row.name); + allocator.free(row.type); + if (row.dflt_value) |dv| allocator.free(dv); + } + if (std.mem.eql(u8, row.name, "extra")) { + found_extra = true; + } } + try std.testing.expect(!found_extra); } - try std.testing.expect(!found_extra); } } diff --git a/src/msgpack_utils_test.zig b/src/msgpack_utils_test.zig index 32a1c272..d418e690 100644 --- a/src/msgpack_utils_test.zig +++ b/src/msgpack_utils_test.zig @@ -75,117 +75,70 @@ test "msgpack_utils: writeMsgPackStr str32 (>65535 bytes)" { // // Invariant: Oversized Payloads Rejected // -// This test MUST FAIL on unfixed code (PackerIO uses DEFAULT_LIMITS which are too permissive). -// Failure confirms the bug exists. When the fix is applied (PackWithLimits + TIGHT_LIMITS), -// this test will PASS. +// Payloads beyond wire_limits (depth 32, array/map 100k, string/bin/ext 1MB) +// must be rejected with the corresponding limit error. +fn writeBe32(buf: []u8, offset: usize, value: u32) void { + buf[offset + 0] = @intCast((value >> 24) & 0xff); + buf[offset + 1] = @intCast((value >> 16) & 0xff); + buf[offset + 2] = @intCast((value >> 8) & 0xff); + buf[offset + 3] = @intCast(value & 0xff); +} + +fn expectDecodeError(allocator: std.mem.Allocator, bytes: []const u8, expected_err: anyerror) !void { + var reader: std.Io.Reader = .fixed(bytes); + const result = msgpack_utils.decode(allocator, &reader); + if (result) |payload| { + payload.free(allocator); + return error.TestUnexpectedResult; + } else |err| { + try testing.expectEqual(expected_err, err); + } +} + test "msgpack: reject oversized payloads (depth, array, map, string)" { const allocator = testing.allocator; - // --- Depth bomb --- - // Craft a MessagePack array nested 33 levels deep. - // Each level: fixarray of 1 element (0x91). Innermost: nil (0xc0). - // wire_limits.max_depth = 32, so 33 levels should trigger MaxDepthExceeded. + // Depth bomb: array nested 33 levels (max_depth = 32). { var depth_bomb: [34]u8 = undefined; for (0..33) |i| { depth_bomb[i] = 0x91; // fixarray of 1 element } depth_bomb[33] = 0xc0; // nil at innermost level - - var reader: std.Io.Reader = .fixed(&depth_bomb); - const result = msgpack_utils.decode(allocator, &reader); - if (result) |payload| { - payload.free(allocator); - // Bug confirmed: decode returned a valid Payload instead of error.MaxDepthExceeded - // Counterexample: depth bomb (20 levels) decoded successfully — PackerIO allows depth up to 1000 - return error.TestUnexpectedResult; - } else |err| { - try testing.expectEqual(error.MaxDepthExceeded, err); - } + try expectDecodeError(allocator, &depth_bomb, error.MaxDepthExceeded); } - // --- Array bomb --- - // Craft a MessagePack array32 header claiming 100,001 elements, followed by 100,001 nil bytes. - // wire_limits.max_array_length = 100,000, so 100,001 elements should trigger ArrayTooLarge. + // Array bomb: array32 claiming 100,001 elements (max_array_length = 100,000). { const count: u32 = 100001; - const array_bomb = try allocator.alloc(u8, 5 + 100001); + const array_bomb = try allocator.alloc(u8, 5 + count); defer allocator.free(array_bomb); - array_bomb[0] = 0xdd; // array32 format byte - array_bomb[1] = @intCast((count >> 24) & 0xff); - array_bomb[2] = @intCast((count >> 16) & 0xff); - array_bomb[3] = @intCast((count >> 8) & 0xff); - array_bomb[4] = @intCast(count & 0xff); - for (5..5 + 100001) |i| { - array_bomb[i] = 0xc0; // nil - } - - var reader: std.Io.Reader = .fixed(array_bomb); - const result = msgpack_utils.decode(allocator, &reader); - if (result) |payload| { - payload.free(allocator); - // Bug confirmed: decode returned a valid Payload instead of error.ArrayTooLarge - // Counterexample: array bomb (2000 elements) decoded successfully — PackerIO allows up to 1,000,000 - return error.TestUnexpectedResult; - } else |err| { - try testing.expectEqual(error.ArrayTooLarge, err); - } + array_bomb[0] = 0xdd; // array32 + writeBe32(array_bomb, 1, count); + @memset(array_bomb[5..], 0xc0); // nil + try expectDecodeError(allocator, array_bomb, error.ArrayTooLarge); } - // --- Map bomb --- - // Craft a MessagePack map32 header claiming 100,001 entries, followed by 200,002 nil bytes (key+value pairs). - // wire_limits.max_map_size = 100,000, so 100,001 entries should trigger MapTooLarge. + // Map bomb: map32 claiming 100,001 entries (max_map_size = 100,000). { const count: u32 = 100001; - const map_bomb = try allocator.alloc(u8, 5 + 200002); + const map_bomb = try allocator.alloc(u8, 5 + 2 * count); defer allocator.free(map_bomb); - map_bomb[0] = 0xdf; // map32 format byte - map_bomb[1] = @intCast((count >> 24) & 0xff); - map_bomb[2] = @intCast((count >> 16) & 0xff); - map_bomb[3] = @intCast((count >> 8) & 0xff); - map_bomb[4] = @intCast(count & 0xff); - for (5..5 + 200002) |i| { - map_bomb[i] = 0xc0; // nil (key and value both nil) - } - - var reader: std.Io.Reader = .fixed(map_bomb); - const result = msgpack_utils.decode(allocator, &reader); - if (result) |payload| { - payload.free(allocator); - // Bug confirmed: decode returned a valid Payload instead of error.MapTooLarge - // Counterexample: map bomb (2000 entries) decoded successfully — PackerIO allows up to 1,000,000 - return error.TestUnexpectedResult; - } else |err| { - try testing.expectEqual(error.MapTooLarge, err); - } + map_bomb[0] = 0xdf; // map32 + writeBe32(map_bomb, 1, count); + @memset(map_bomb[5..], 0xc0); // nil key+value pairs + try expectDecodeError(allocator, map_bomb, error.MapTooLarge); } - // --- String bomb --- - // Craft a MessagePack str32 header claiming 2 MB, followed by 2 MB of zero bytes. - // wire_limits.max_string_length = 1 MB, so 2 MB should trigger StringTooLong. + // String bomb: str32 claiming 2 MB (max_string_length = 1 MB). { const str_len: u32 = 2 * 1024 * 1024; - const total_size = 5 + str_len; - const string_bomb = try allocator.alloc(u8, total_size); + const string_bomb = try allocator.alloc(u8, 5 + str_len); defer allocator.free(string_bomb); - - string_bomb[0] = 0xdb; // str32 format byte - string_bomb[1] = @intCast((str_len >> 24) & 0xff); - string_bomb[2] = @intCast((str_len >> 16) & 0xff); - string_bomb[3] = @intCast((str_len >> 8) & 0xff); - string_bomb[4] = @intCast(str_len & 0xff); - @memset(string_bomb[5..], 0); // zero bytes for string content - - var reader: std.Io.Reader = .fixed(string_bomb); - const result = msgpack_utils.decode(allocator, &reader); - if (result) |payload| { - payload.free(allocator); - // Bug confirmed: decode returned a valid Payload instead of error.StringTooLong - // Counterexample: string bomb (128 KB) decoded successfully — PackerIO allows up to 100 MB - return error.TestUnexpectedResult; - } else |err| { - try testing.expectEqual(error.StringTooLong, err); - } + string_bomb[0] = 0xdb; // str32 + writeBe32(string_bomb, 1, str_len); + @memset(string_bomb[5..], 0); + try expectDecodeError(allocator, string_bomb, error.StringTooLong); } } @@ -261,8 +214,6 @@ test "msgpack: boundary success (31 depth, 100000 items, 1MB str)" { const allocator = testing.allocator; // --- Depth exactly 31 (max allowed with max_depth=32) --- - // The zig-msgpack depth check fires when parse_stack.items.len >= max_depth (32). - // So 31 levels of nesting is the maximum that succeeds. { var depth31: [32]u8 = undefined; for (0..31) |i| { @@ -278,15 +229,11 @@ test "msgpack: boundary success (31 depth, 100000 items, 1MB str)" { // --- Array of exactly 100,000 elements --- { const count: u32 = 100000; - const total_size = 5 + count; - const array_exact = try allocator.alloc(u8, total_size); + const array_exact = try allocator.alloc(u8, 5 + count); defer allocator.free(array_exact); - array_exact[0] = 0xdd; // array32 format byte - array_exact[1] = @intCast((count >> 24) & 0xff); - array_exact[2] = @intCast((count >> 16) & 0xff); - array_exact[3] = @intCast((count >> 8) & 0xff); - array_exact[4] = @intCast(count & 0xff); + array_exact[0] = 0xdd; // array32 + writeBe32(array_exact, 1, count); @memset(array_exact[5..], 0xc0); // nil var reader: std.Io.Reader = .fixed(array_exact); @@ -294,24 +241,66 @@ test "msgpack: boundary success (31 depth, 100000 items, 1MB str)" { result.free(allocator); } + // --- Map of exactly 100,000 entries --- + { + const count: u32 = 100000; + const map_exact = try allocator.alloc(u8, 5 + 2 * count); + defer allocator.free(map_exact); + + map_exact[0] = 0xdf; // map32 + writeBe32(map_exact, 1, count); + @memset(map_exact[5..], 0xc0); // nil key+value pairs + + var reader: std.Io.Reader = .fixed(map_exact); + const result = try msgpack_utils.decode(allocator, &reader); + result.free(allocator); + } + // --- String of exactly 1MB bytes --- { const str_len: u32 = 1 * 1024 * 1024; - const total_size = 5 + str_len; - const string_exact = try allocator.alloc(u8, total_size); + const string_exact = try allocator.alloc(u8, 5 + str_len); defer allocator.free(string_exact); - string_exact[0] = 0xdb; // str32 format byte - string_exact[1] = @intCast((str_len >> 24) & 0xff); - string_exact[2] = @intCast((str_len >> 16) & 0xff); - string_exact[3] = @intCast((str_len >> 8) & 0xff); - string_exact[4] = @intCast(str_len & 0xff); + string_exact[0] = 0xdb; // str32 + writeBe32(string_exact, 1, str_len); @memset(string_exact[5..], 0); var reader: std.Io.Reader = .fixed(string_exact); const result = try msgpack_utils.decode(allocator, &reader); result.free(allocator); } + + // --- Bin of exactly 1MB bytes --- + { + const bin_len: u32 = 1 * 1024 * 1024; + const bin_exact = try allocator.alloc(u8, 5 + bin_len); + defer allocator.free(bin_exact); + + bin_exact[0] = 0xc6; // bin32 + writeBe32(bin_exact, 1, bin_len); + @memset(bin_exact[5..], 0); + + var reader: std.Io.Reader = .fixed(bin_exact); + const result = try msgpack_utils.decode(allocator, &reader); + result.free(allocator); + } + + // --- Ext of exactly 1MB bytes --- + { + const ext_len: u32 = 1 * 1024 * 1024; + const ext_exact = try allocator.alloc(u8, 5 + 1 + ext_len); + defer allocator.free(ext_exact); + + ext_exact[0] = 0xc9; // ext32 + writeBe32(ext_exact, 1, ext_len); + ext_exact[5] = 0x00; // ext type byte + @memset(ext_exact[6..], 0); + + var reader: std.Io.Reader = .fixed(ext_exact); + const result = try msgpack_utils.decode(allocator, &reader); + result.free(allocator); + } } // Boundary failure properties @@ -329,64 +318,68 @@ test "msgpack: reject one-over-boundary payloads" { depth32[i] = 0x91; // fixarray of 1 element } depth32[32] = 0xc0; // nil at innermost level - - var reader: std.Io.Reader = .fixed(&depth32); - const result = msgpack_utils.decode(allocator, &reader); - if (result) |payload| { - payload.free(allocator); - return error.TestUnexpectedResult; - } else |err| { - try testing.expectEqual(error.MaxDepthExceeded, err); - } + try expectDecodeError(allocator, &depth32, error.MaxDepthExceeded); } // --- Array of exactly 100,001 elements (one over max_array_length=100,000) --- { const count: u32 = 100001; - const total_size = 5 + count; - const array_over = try allocator.alloc(u8, total_size); + const array_over = try allocator.alloc(u8, 5 + count); defer allocator.free(array_over); - array_over[0] = 0xdd; // array32 format byte - array_over[1] = @intCast((count >> 24) & 0xff); - array_over[2] = @intCast((count >> 16) & 0xff); - array_over[3] = @intCast((count >> 8) & 0xff); - array_over[4] = @intCast(count & 0xff); + array_over[0] = 0xdd; // array32 + writeBe32(array_over, 1, count); @memset(array_over[5..], 0xc0); // nil + try expectDecodeError(allocator, array_over, error.ArrayTooLarge); + } - var reader: std.Io.Reader = .fixed(array_over); - const result = msgpack_utils.decode(allocator, &reader); - if (result) |payload| { - payload.free(allocator); - return error.TestUnexpectedResult; - } else |err| { - try testing.expectEqual(error.ArrayTooLarge, err); - } + // --- Map of exactly 100,001 entries (one over max_map_size=100,000) --- + { + const count: u32 = 100001; + const map_over = try allocator.alloc(u8, 5 + 2 * count); + defer allocator.free(map_over); + + map_over[0] = 0xdf; // map32 + writeBe32(map_over, 1, count); + @memset(map_over[5..], 0xc0); // nil key+value pairs + try expectDecodeError(allocator, map_over, error.MapTooLarge); } // --- String of exactly 1MB+1 bytes (one over max_string_length=1MB) --- { const str_len: u32 = 1 * 1024 * 1024 + 1; - const total_size = 5 + str_len; - const string_over = try allocator.alloc(u8, total_size); + const string_over = try allocator.alloc(u8, 5 + str_len); defer allocator.free(string_over); - string_over[0] = 0xdb; // str32 format byte - string_over[1] = @intCast((str_len >> 24) & 0xff); - string_over[2] = @intCast((str_len >> 16) & 0xff); - string_over[3] = @intCast((str_len >> 8) & 0xff); - string_over[4] = @intCast(str_len & 0xff); + string_over[0] = 0xdb; // str32 + writeBe32(string_over, 1, str_len); @memset(string_over[5..], 0); + try expectDecodeError(allocator, string_over, error.StringTooLong); + } - var reader: std.Io.Reader = .fixed(string_over); - const result = msgpack_utils.decode(allocator, &reader); - if (result) |payload| { - payload.free(allocator); - // Limit not enforced: string of 64KB+1 decoded successfully — unfixed code uses DEFAULT_LIMITS (max_string_length=100MB) - return error.TestUnexpectedResult; - } else |err| { - try testing.expectEqual(error.StringTooLong, err); - } + // --- Bin of exactly 1MB+1 bytes (one over max_bin_length=1MB) --- + { + const bin_len: u32 = 1 * 1024 * 1024 + 1; + const bin_over = try allocator.alloc(u8, 5 + bin_len); + defer allocator.free(bin_over); + + bin_over[0] = 0xc6; // bin32 + writeBe32(bin_over, 1, bin_len); + @memset(bin_over[5..], 0); + try expectDecodeError(allocator, bin_over, error.BinDataLengthTooLong); + } + + // --- Ext of exactly 1MB+1 bytes (one over max_ext_length=1MB) --- + { + const ext_len: u32 = 1 * 1024 * 1024 + 1; + const ext_over = try allocator.alloc(u8, 5 + 1 + ext_len); + defer allocator.free(ext_over); + + ext_over[0] = 0xc9; // ext32 + writeBe32(ext_over, 1, ext_len); + ext_over[5] = 0x00; // ext type byte + @memset(ext_over[6..], 0); + try expectDecodeError(allocator, ext_over, error.ExtDataTooLarge); } } @@ -410,7 +403,11 @@ fn verifyPayloadEquality(expected: msgpack.Payload, actual: msgpack.Payload) !vo .str => |v| try testing.expectEqualStrings(v.value(), actual.str.value()), .map => { try testing.expectEqual(expected.map.count(), actual.map.count()); - // In a real test we'd iterate and compare entries + var it = expected.map.iterator(); + while (it.next()) |entry| { + const actual_val = (try actual.mapGetGeneric(entry.key_ptr.*)) orelse return error.TestExpectedValue; + try verifyPayloadEquality(entry.value_ptr.*, actual_val); + } }, else => {}, } diff --git a/src/server_init_test.zig b/src/server_init_test.zig index e3a23523..4391a970 100644 --- a/src/server_init_test.zig +++ b/src/server_init_test.zig @@ -42,30 +42,23 @@ test "server: initialization is idempotent" { }); // Property: Multiple init/deinit cycles should not leak memory - // Test with 1 cycle first to debug leaks - const num_cycles = 1; - var i: usize = 0; - while (i < num_cycles) : (i += 1) { + const num_cycles = 2; + for (0..num_cycles) |_| { // Initialize server with unique data directory and custom schema path - const server = try ZyncBaseServer.initDetailed(allocator, null, data_dir, schema_file_path, null); - std.log.debug("Server initialized", .{}); - defer { - std.log.debug("About to call server.deinit()", .{}); - server.deinit(); - std.log.debug("server.deinit() returned", .{}); - } + // The block scope ensures deinit runs before the next cycle re-creates + // the data directory (deleteTree after the block). + { + const server = try ZyncBaseServer.initDetailed(allocator, null, data_dir, schema_file_path, null); + defer server.deinit(); - // Verify server is properly initialized - try testing.expect(@intFromPtr(&server.memory_strategy) != 0); - try testing.expect(@intFromPtr(&server.violation_tracker) != 0); - try testing.expect(@intFromPtr(&server.subscription_engine) != 0); - try testing.expect(@intFromPtr(&server.checkpoint_manager) != 0); - try testing.expect(@intFromPtr(&server.storage_engine) != 0); - try testing.expect(@intFromPtr(&server.websocket_server) != 0); - try testing.expect(@intFromPtr(&server.message_handler) != 0); + // Verify server is properly initialized + try testing.expect(server.schema.tables.len > 0); + const loaded_data_dir = server.config.data_dir; + try testing.expectEqualStrings(data_dir, loaded_data_dir); - // Verify shutdown flag is initialized to false - try testing.expect(!server.shutdown_requested.load(.acquire)); + // Verify shutdown flag is initialized to false + try testing.expect(!server.shutdown_requested.load(.acquire)); + } // Clean up database file between cycles std.fs.cwd().deleteTree(data_dir) catch {}; // zwanzig-disable-line: empty-catch-engine diff --git a/src/storage_engine_error_test.zig b/src/storage_engine_error_test.zig index e0c39a33..94ace6a4 100644 --- a/src/storage_engine_error_test.zig +++ b/src/storage_engine_error_test.zig @@ -38,7 +38,7 @@ test "storage: error handling invalid database path" { } } } -test "storage: error handling read-only filesystem" { +test "storage: write/flush/read round-trip on file-backed engine" { const allocator = testing.allocator; const table = schema_helpers.makeTable("data_table", &.{schema_helpers.makeField("val", .text)}); var ctx: sth.EngineTestContext = undefined; @@ -101,20 +101,30 @@ test "storage: error handling concurrent access safety" { storage: *StorageEngine, allocator: std.mem.Allocator, }; + const ThreadResult = struct { + outcome: ?anyerror = null, + }; const runRead = struct { - fn run(t_ctx: ThreadContext, table_index: usize) void { - const record = sth.readDoc(t_ctx.allocator, t_ctx.storage, table_index, 1, 1) catch return; // zwanzig-disable-line: swallowed-error + fn run(t_ctx: ThreadContext, table_index: usize, result: *ThreadResult) void { + runErr(t_ctx, table_index) catch |err| { // zwanzig-disable-line: swallowed-error + result.outcome = err; + }; + } + fn runErr(t_ctx: ThreadContext, table_index: usize) !void { + const record = try sth.readDoc(t_ctx.allocator, t_ctx.storage, table_index, 1, 1); defer if (record) |r| r.deinit(t_ctx.allocator); } }.run; var threads: [4]std.Thread = undefined; + var results = [_]ThreadResult{.{}} ** 4; const tbl_md = ctx.schema.table("data_table") orelse return error.UnknownTable; - for (&threads) |*t| { - t.* = try std.Thread.spawn(.{}, runRead, .{ ThreadContext{ .storage = storage, .allocator = allocator }, tbl_md.index }); + for (&threads, &results) |*t, *result| { + t.* = try std.Thread.spawn(.{}, runRead, .{ ThreadContext{ .storage = storage, .allocator = allocator }, tbl_md.index, result }); } for (threads) |t| t.join(); + for (results) |result| try testing.expect(result.outcome == null); } -test "storage: error handling empty paths" { +test "storage: write/flush/read round-trip with empty value" { const allocator = testing.allocator; const table = schema_helpers.makeTable("data_table", &.{schema_helpers.makeField("val", .text)}); var ctx: sth.EngineTestContext = undefined; diff --git a/src/storage_engine_stability_test.zig b/src/storage_engine_stability_test.zig index b18b81ae..29de1863 100644 --- a/src/storage_engine_stability_test.zig +++ b/src/storage_engine_stability_test.zig @@ -6,11 +6,9 @@ const sth = @import("storage_engine_test_helpers.zig"); const testing = std.testing; -// This property test verifies that the server remains stable when database errors occur: -// 1. No panics or crashes on database errors -// 2. Server continues operating after database errors -// 3. Error recovery mechanisms work correctly -// 4. Concurrent operations remain safe during errors +// This property test verifies that the storage engine stays stable under +// concurrent insert/read/delete operations while errors are injected: +// no panics or crashes occur, and the engine keeps operating. // // We test various error scenarios to ensure the server never crashes: // - Multiple concurrent operations during errors diff --git a/src/storage_engine_test.zig b/src/storage_engine_test.zig index 00e76929..61a043d2 100644 --- a/src/storage_engine_test.zig +++ b/src/storage_engine_test.zig @@ -15,6 +15,7 @@ const typed = @import("typed/types.zig"); const testing = std.testing; const SendQueueEntry = send_queue_mod.Entry; +const StorageEngine = sth.StorageEngine; const DirectWriterContext = struct { allocator: std.mem.Allocator, @@ -1100,47 +1101,56 @@ test "StorageEngine: failed document cache update evicts stale entry post-commit try testing.expectEqualStrings("new_val", record.?.values[val_field_idx].scalar.text); } -const StorageEngine = sth.StorageEngine; -test "storage: engine initialization errors" { +test "storage: engine init rejects empty data dir" { const allocator = testing.allocator; var ms: sth.MemoryStrategy = undefined; try ms.init(allocator); defer std.debug.assert(ms.deinit() == .ok); - // Test 1: Invalid directory path const invalid_dir = ""; - var sm1 = try sth.createDummySchema(allocator); - defer sm1.deinit(); - var engine1: StorageEngine = undefined; - const result1 = engine1.init(allocator, &ms, invalid_dir, &sm1, .{}, .{ .in_memory = false }, null, null); - try testing.expectError(error.InvalidDataDir, result1); - - // Test 2: Path that is a file, not a directory - var context_not_dir = try sth.TestContext.init(allocator, "storage-not-dir"); - defer context_not_dir.deinit(); - const test_file = try std.fs.path.join(allocator, &.{ context_not_dir.test_dir, "test_file_not_dir.txt" }); + var sm = try sth.createDummySchema(allocator); + defer sm.deinit(); + var engine: StorageEngine = undefined; + const result = engine.init(allocator, &ms, invalid_dir, &sm, .{}, .{ .in_memory = false }, null, null); + try testing.expectError(error.InvalidDataDir, result); +} + +test "storage: engine init rejects file path as data dir" { + const allocator = testing.allocator; + var ms: sth.MemoryStrategy = undefined; + try ms.init(allocator); + defer std.debug.assert(ms.deinit() == .ok); + + var context = try sth.TestContext.init(allocator, "storage-not-dir"); + defer context.deinit(); + const test_file = try std.fs.path.join(allocator, &.{ context.test_dir, "test_file_not_dir.txt" }); defer allocator.free(test_file); - // Create a file const file = try std.fs.cwd().createFile(test_file, .{}); file.close(); - var sm2 = try sth.createDummySchema(allocator); - defer sm2.deinit(); - var engine2: StorageEngine = undefined; - const result2 = engine2.init(allocator, &ms, test_file, &sm2, .{}, .{ .in_memory = false }, null, null); - try testing.expectError(error.NotDir, result2); + var sm = try sth.createDummySchema(allocator); + defer sm.deinit(); + var engine: StorageEngine = undefined; + const result = engine.init(allocator, &ms, test_file, &sm, .{}, .{ .in_memory = false }, null, null); + try testing.expectError(error.NotDir, result); +} - // Test 3: Valid initialization should succeed - var context_valid = try sth.TestContext.init(allocator, "storage-init-valid"); - defer context_valid.deinit(); - const test_dir = context_valid.test_dir; +test "storage: engine init creates database file" { + const allocator = testing.allocator; + var ms: sth.MemoryStrategy = undefined; + try ms.init(allocator); + defer std.debug.assert(ms.deinit() == .ok); + + var context = try sth.TestContext.init(allocator, "storage-init-valid"); + defer context.deinit(); + const test_dir = context.test_dir; - var sm3 = try sth.createDummySchema(allocator); - defer sm3.deinit(); - var engine3: StorageEngine = undefined; - try engine3.init(allocator, &ms, test_dir, &sm3, .{}, .{ .in_memory = false, .reader_pool_size = 1 }, null, null); - defer engine3.deinit(); + var sm = try sth.createDummySchema(allocator); + defer sm.deinit(); + var engine: StorageEngine = undefined; + try engine.init(allocator, &ms, test_dir, &sm, .{}, .{ .in_memory = false, .reader_pool_size = 1 }, null, null); + defer engine.deinit(); // Verify database file was created const db_path = try std.fs.path.join(allocator, &.{ test_dir, "zyncbase.db" }); @@ -1155,7 +1165,7 @@ test "storage: thread-safe engine access" { var fields_arr = [_]sth.Field{schema_helpers.makeField("val", .text)}; const table = schema_helpers.makeTable("test", &fields_arr); var ctx: sth.EngineTestContext = undefined; - try sth.setupEngine(&ctx, allocator, "prop-multi-table", table); + try sth.setupEngine(&ctx, allocator, "storage-thread-safe", table); defer ctx.deinit(); const engine = &ctx.engine; @@ -1164,9 +1174,15 @@ test "storage: thread-safe engine access" { const ops_per_thread = 50; const ThreadContext = struct { ctx: *sth.EngineTestContext, + result: *?anyerror, }; const WriteThread = struct { - fn run(t_ctx: ThreadContext, thread_id: usize) !void { + fn run(t_ctx: ThreadContext, thread_id: usize) void { + runErr(t_ctx, thread_id) catch |err| { + t_ctx.result.* = err; + }; + } + fn runErr(t_ctx: ThreadContext, thread_id: usize) !void { var i: usize = 0; while (i < ops_per_thread) : (i += 1) { const key: u128 = thread_id * 1_000 + i + 1; @@ -1181,7 +1197,12 @@ test "storage: thread-safe engine access" { } }; const ReadThread = struct { - fn run(eng: *StorageEngine, table_index: usize, thread_id: usize) !void { + fn run(eng: *StorageEngine, table_index: usize, thread_id: usize, result: *?anyerror) void { + runErr(eng, table_index, thread_id) catch |err| { + result.* = err; + }; + } + fn runErr(eng: *StorageEngine, table_index: usize, thread_id: usize) !void { var i: usize = 0; while (i < ops_per_thread) : (i += 1) { const key: u128 = (thread_id % (num_threads / 2)) * 1_000 + i + 1; @@ -1191,16 +1212,19 @@ test "storage: thread-safe engine access" { } }; + var write_results = [_]?anyerror{null} ** (num_threads / 2); + var read_results = [_]?anyerror{null} ** (num_threads / 2); + // Spawn write threads var write_threads: [num_threads / 2]std.Thread = undefined; for (&write_threads, 0..) |*thread, i| { - thread.* = try std.Thread.spawn(.{}, WriteThread.run, .{ ThreadContext{ .ctx = &ctx }, i }); + thread.* = try std.Thread.spawn(.{}, WriteThread.run, .{ ThreadContext{ .ctx = &ctx, .result = &write_results[i] }, i }); } // Spawn read threads var read_threads: [num_threads / 2]std.Thread = undefined; const test_table = try ctx.table("test"); for (&read_threads, 0..) |*thread, i| { - thread.* = try std.Thread.spawn(.{}, ReadThread.run, .{ engine, test_table.metadata.index, i }); + thread.* = try std.Thread.spawn(.{}, ReadThread.run, .{ engine, test_table.metadata.index, i, &read_results[i] }); } // Wait for all threads for (write_threads) |thread| { @@ -1209,12 +1233,16 @@ test "storage: thread-safe engine access" { for (read_threads) |thread| { thread.join(); } + // All workers must have completed without error + for (write_results) |r| try testing.expect(r == null); + for (read_results) |r| try testing.expect(r == null); // Flush writes and verify data try engine.flushPendingWrites(); // Verify some data was written const record = try test_table.readDoc(allocator, 1, 1); defer if (record) |r| r.deinit(allocator); try testing.expect(record != null); + _ = try sth.expectFieldString(record.?, test_table.metadata, "val", "{\"thread\":0,\"op\":0}"); } test "storage: connection pool reuse and release" { @@ -1222,7 +1250,7 @@ test "storage: connection pool reuse and release" { var fields_arr = [_]sth.Field{schema_helpers.makeField("val", .text)}; const table = schema_helpers.makeTable("test", &fields_arr); var ctx: sth.EngineTestContext = undefined; - try sth.setupEngine(&ctx, allocator, "prop-many-ns", table); + try sth.setupEngine(&ctx, allocator, "storage-conn-pool", table); defer ctx.deinit(); const engine = &ctx.engine; @@ -1251,7 +1279,7 @@ test "storage: persistence round-trip (various types)" { var fields_arr = [_]sth.Field{schema_helpers.makeField("val", .text)}; const table = schema_helpers.makeTable("test", &fields_arr); var ctx: sth.EngineTestContext = undefined; - try sth.setupEngine(&ctx, allocator, "prop-burst", table); + try sth.setupEngine(&ctx, allocator, "storage-persistence", table); defer ctx.deinit(); const engine = &ctx.engine; @@ -1280,6 +1308,7 @@ test "storage: persistence round-trip (various types)" { const record = try test_table.readDoc(allocator, tc.id, tc.namespace_id); defer if (record) |r| r.deinit(allocator); try testing.expect(record != null); + _ = try sth.expectFieldString(record.?, test_table.metadata, "val", tc.value); } } @@ -1366,6 +1395,9 @@ test "storage: transaction isolation and consistency" { try testing.expect(record_up1 != null); try testing.expect(record_up2 != null); try testing.expect(record_n3 != null); + _ = try sth.expectFieldString(record_up1.?, test_table.metadata, "val", "updated1"); + _ = try sth.expectFieldString(record_up2.?, test_table.metadata, "val", "updated2"); + _ = try sth.expectFieldString(record_n3.?, test_table.metadata, "val", "new3"); } test "storage: concurrent batch processing" { @@ -1505,6 +1537,7 @@ test "storage: schema update integrity" { const record1 = try test_table.readDoc(allocator, 1, 1); defer if (record1) |r| r.deinit(allocator); try testing.expect(record1 != null); + _ = try sth.expectFieldString(record1.?, test_table.metadata, "val1", "value1"); // New data with new field try ctx.insertInt("test", 2, 1, "val2", 42); @@ -1532,6 +1565,7 @@ test "storage: random operations fuzzing" { var prng = std.Random.DefaultPrng.init(0); const rand = prng.random(); + const items_index = ctx.tableIndex("items"); var i: usize = 0; while (i < 200) : (i += 1) { const op = rand.intRangeAtMost(u8, 0, 3); @@ -1546,7 +1580,7 @@ test "storage: random operations fuzzing" { }, 1 => { // Delete - try engine.enqueueWriteOp(.{ .delete = .{ .table_index = 0, .id = id, .namespace_id = ns } }); + try engine.enqueueWriteOp(.{ .delete = .{ .table_index = items_index, .id = id, .namespace_id = ns } }); }, 2 => { // Query diff --git a/src/uwebsockets_wrapper_test.zig b/src/uwebsockets_wrapper_test.zig index 03fe406b..7a2cf229 100644 --- a/src/uwebsockets_wrapper_test.zig +++ b/src/uwebsockets_wrapper_test.zig @@ -251,7 +251,7 @@ const destroyMockWebSocket = helpers.destroyMockWebSocket; // 4. Close callbacks receive correct close code and message // 5. Callbacks are not invoked if not registered -test "ws: callbacks invoked for all events" { +test "ws: callback contract - handlers invoked per registration" { const allocator = testing.allocator; // Test case structure @@ -308,27 +308,7 @@ test "ws: callbacks invoked for all events" { std.log.debug("Running test case: {s}\n", .{tc.name}); // Create callback context to track invocations - var ctx = CallbackContext{ - .open_called = 0, - .message_called = 0, - .close_called = 0, - .last_message = null, - .last_message_type = null, - .last_close_code = null, - .last_close_message = null, - .received_user_data = null, - }; - - // Create server - const config = WebSocketServer.Config{ - .port = 8080, - .host = "127.0.0.1", - .ssl = false, - }; - - var server: WebSocketServer = undefined; - try server.init(allocator, config); - defer server.deinit(); + var ctx = CallbackContext{}; // Build handlers based on test case const handlers = WebSocketHandlers{ @@ -337,12 +317,9 @@ test "ws: callbacks invoked for all events" { .on_close = if (tc.register_close) testOnCloseProperty else null, }; - // Register handlers with context as user data - server.registerWebSocketHandlers("/*", handlers, &ctx); - // Simulate WebSocket events by calling the handlers directly - // In a real integration test, we would connect a client and trigger events - // For property testing, we verify the handler registration and invocation logic + // (the wrapper exposes no dispatch API; the callback contract is + // verified by invoking each registered handler in isolation). // Simulate open event if (tc.register_open) { @@ -416,16 +393,7 @@ test "ws: message callback content and type" { }; for (message_tests) |mt| { - var ctx = CallbackContext{ - .open_called = 0, - .message_called = 0, - .close_called = 0, - .last_message = null, - .last_message_type = null, - .last_close_code = null, - .last_close_message = null, - .received_user_data = null, - }; + var ctx = CallbackContext{}; const config = WebSocketServer.Config{ .port = 8080, @@ -478,16 +446,7 @@ test "ws: close callback code and message" { }; for (close_tests) |ct| { - var ctx = CallbackContext{ - .open_called = 0, - .message_called = 0, - .close_called = 0, - .last_message = null, - .last_message_type = null, - .last_close_code = null, - .last_close_message = null, - .received_user_data = null, - }; + var ctx = CallbackContext{}; const config = WebSocketServer.Config{ .port = 8080, @@ -521,19 +480,10 @@ test "ws: close callback code and message" { } } -test "ws: callbacks invoked exactly once" { +test "ws: callback invocation counts reflect events" { const allocator = testing.allocator; - var ctx = CallbackContext{ - .open_called = 0, - .message_called = 0, - .close_called = 0, - .last_message = null, - .last_message_type = null, - .last_close_code = null, - .last_close_message = null, - .received_user_data = null, - }; + var ctx = CallbackContext{}; const config = WebSocketServer.Config{ .port = 8080, @@ -555,6 +505,7 @@ test "ws: callbacks invoked exactly once" { // Simulate multiple events var mock_ws = createMockWebSocket(allocator); + defer destroyMockWebSocket(allocator, &mock_ws); // Open event if (handlers.on_open) |handler| { @@ -575,26 +526,19 @@ test "ws: callbacks invoked exactly once" { handler(&mock_ws, 1000, "closing", &ctx); } try testing.expectEqual(@as(u32, 1), ctx.close_called); - - // Verify total invocations - try testing.expectEqual(@as(u32, 1), ctx.open_called); - try testing.expectEqual(@as(u32, 3), ctx.message_called); - try testing.expectEqual(@as(u32, 1), ctx.close_called); - - destroyMockWebSocket(allocator, &mock_ws); } // Helper types and functions const CallbackContext = struct { - open_called: u32, - message_called: u32, - close_called: u32, - last_message: ?[]const u8, - last_message_type: ?MessageType, - last_close_code: ?i32, - last_close_message: ?[]const u8, - received_user_data: ?*anyopaque, + open_called: u32 = 0, + message_called: u32 = 0, + close_called: u32 = 0, + last_message: ?[]const u8 = null, + last_message_type: ?MessageType = null, + last_close_code: ?i32 = null, + last_close_message: ?[]const u8 = null, + received_user_data: ?*anyopaque = null, }; fn testOnOpenProperty(ws: *WebSocket, user_data: ?*anyopaque) void { From fbadb6981d896cbd3a68535b121b0a0a52cf3e24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20Dokumac=C4=B1?= Date: Sun, 2 Aug 2026 10:49:35 +0200 Subject: [PATCH 3/8] chore: address review comments --- scripts/unused_pub_detector.js | 4 +++- src/checkpoint_worker_test.zig | 29 ++++++++++++++++++++++++++--- src/migration_executor_test.zig | 2 +- src/storage_engine_error_test.zig | 9 ++++++--- 4 files changed, 36 insertions(+), 8 deletions(-) diff --git a/scripts/unused_pub_detector.js b/scripts/unused_pub_detector.js index 8fe4d9ae..370f9fcd 100644 --- a/scripts/unused_pub_detector.js +++ b/scripts/unused_pub_detector.js @@ -6,7 +6,7 @@ const EXTERNAL = new Set(["std","builtin","sqlite","msgpack","httpx"]); // Boundary-aware test-path classification on canonical paths ("src/foo_test.zig"): // *_test.zig suffix, test_all.zig, *test_helper(s).zig, *test_runner.zig. // Does NOT match ordinary production names such as "latest.zig". -const TEST_RE = /(?:^|\/)(?:[^/]+_test\.zig|test_all\.zig|[^/]*test[-_](?:helper|runner)[^/]*\.zig)$/; +const TEST_RE = /(?:^|\/)(?:[^/]+_test\.zig|test_all\.zig|(?:[^/]+[-_])?test[-_](?:helper|runner)[^/]*\.zig)$/; if (process.argv.includes("--selftest")) { const cases = [ @@ -16,6 +16,8 @@ if (process.argv.includes("--selftest")) { ["src/schema/test_helpers.zig", true], ["src/timed_test_runner.zig", true], ["src/latest.zig", false], + ["src/contest_helpers.zig", false], + ["src/latest_runner.zig", false], ["src/main.zig", false], ["src/server.zig", false], ]; diff --git a/src/checkpoint_worker_test.zig b/src/checkpoint_worker_test.zig index 4923744b..9eb89745 100644 --- a/src/checkpoint_worker_test.zig +++ b/src/checkpoint_worker_test.zig @@ -3,6 +3,7 @@ const std = @import("std"); const checkpoint_helpers = @import("checkpoint_test_helpers.zig"); const CheckpointWorker = @import("checkpoint_worker.zig").CheckpointWorker; const storage_mod = @import("storage_engine.zig"); +const sth = @import("storage_engine_test_helpers.zig"); const testing = std.testing; @@ -335,11 +336,21 @@ test "checkpoint: integrity - no data loss occurs during checkpoint" { const manager = &ctx.manager; - // Property: Checkpoint should not lose data - // We verify this by checking that metrics are consistent before and after + // Insert representative records through the storage engine the + // checkpoint worker manages, then flush them to SQLite. + const table_metadata = ctx.schema.table("items") orelse return error.MissingItemsTable; + const fixture = sth.TableFixture{ + .engine = &ctx.storage_engine, + .metadata = table_metadata, + }; + const namespace_id: i64 = 1; + try fixture.insertText(1, namespace_id, "name", "alpha"); + try fixture.insertText(2, namespace_id, "name", "beta"); + try fixture.flush(); + const metrics_before = manager.getMetrics(); - // Simulate WAL growth + // Simulate WAL growth (in-memory WAL reports 0 bytes) manager.wal_size.store(2048, .release); // Exceed threshold // Verify shouldCheckpoint returns true @@ -355,6 +366,18 @@ test "checkpoint: integrity - no data loss occurs during checkpoint" { const metrics_after = manager.getMetrics(); try testing.expect(metrics_after.checkpoint_count == metrics_before.checkpoint_count + 1); try testing.expect(metrics_after.last_checkpoint_time >= metrics_before.last_checkpoint_time); + + // Property: Checkpoint must not lose data — every record inserted + // before the checkpoint must be readable afterwards, unchanged. + const alpha = try sth.readDoc(allocator, &ctx.storage_engine, table_metadata.index, 1, namespace_id); + defer if (alpha) |r| r.deinit(allocator); + try testing.expect(alpha != null); + _ = try sth.expectFieldString(alpha.?, table_metadata, "name", "alpha"); + + const beta = try sth.readDoc(allocator, &ctx.storage_engine, table_metadata.index, 2, namespace_id); + defer if (beta) |r| r.deinit(allocator); + try testing.expect(beta != null); + _ = try sth.expectFieldString(beta.?, table_metadata, "name", "beta"); } test "checkpoint: WAL size management - size decreases or stays same after success" { diff --git a/src/migration_executor_test.zig b/src/migration_executor_test.zig index 16a55c25..83ba5d6f 100644 --- a/src/migration_executor_test.zig +++ b/src/migration_executor_test.zig @@ -517,7 +517,7 @@ test "migration_executor: major version bump is refused" { defer allocator.free(ddl); try execMultiSql(&db, allocator, ddl); - // Persisted version: major = 1 + // Target major is always one greater than the persisted major. const target_major = persisted_major + 1; const persisted_ver = try std.fmt.allocPrint(allocator, "{d}.0.0", .{persisted_major}); diff --git a/src/storage_engine_error_test.zig b/src/storage_engine_error_test.zig index 94ace6a4..c465d8fb 100644 --- a/src/storage_engine_error_test.zig +++ b/src/storage_engine_error_test.zig @@ -54,9 +54,9 @@ test "storage: write/flush/read round-trip on file-backed engine" { try storage.flushPendingWrites(); // Verify we can read it back { - const record = try tbl.readDoc(allocator, 1, 1); - defer if (record) |r| r.deinit(allocator); - try testing.expect(record != null); + var doc = try tbl.getOne(allocator, 1, 1); + defer doc.deinit(); + _ = try doc.expectFieldString("val", "value1"); } } test "storage: error handling constraint violations" { @@ -118,8 +118,11 @@ test "storage: error handling concurrent access safety" { var threads: [4]std.Thread = undefined; var results = [_]ThreadResult{.{}} ** 4; const tbl_md = ctx.schema.table("data_table") orelse return error.UnknownTable; + var spawned: usize = 0; + errdefer for (threads[0..spawned]) |t| t.join(); for (&threads, &results) |*t, *result| { t.* = try std.Thread.spawn(.{}, runRead, .{ ThreadContext{ .storage = storage, .allocator = allocator }, tbl_md.index, result }); + spawned += 1; } for (threads) |t| t.join(); for (results) |result| try testing.expect(result.outcome == null); From 6805a29d2aca2c0401c45ef267cc27e0962c7f72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20Dokumac=C4=B1?= Date: Sun, 2 Aug 2026 13:12:34 +0200 Subject: [PATCH 4/8] chore: address review comments --- src/config/loader_test.zig | 59 +++++++------- src/logging_test.zig | 45 ++++++++--- src/migration_executor_test.zig | 108 +++++++++----------------- src/msgpack_utils_test.zig | 31 +------- src/storage_engine_error_test.zig | 11 +-- src/storage_engine_stability_test.zig | 13 ++-- src/storage_engine_test.zig | 42 ++++------ src/test_all.zig | 2 +- 8 files changed, 134 insertions(+), 177 deletions(-) diff --git a/src/config/loader_test.zig b/src/config/loader_test.zig index ac0f1f1b..a9a2a07d 100644 --- a/src/config/loader_test.zig +++ b/src/config/loader_test.zig @@ -8,6 +8,23 @@ const schema_helpers = @import("../schema/test_helpers.zig"); const ConfigLoader = @import("loader.zig").ConfigLoader; const Config = @import("state.zig").Config; +/// Writes an empty schema file and the given config content under `test_dir`, +/// returning an allocated path to the config file. The caller owns the returned +/// path and schema content. +fn writeConfigWithSchema( + allocator: std.mem.Allocator, + test_dir: []const u8, + config_file_name: []const u8, + schema_file_path: []const u8, + config_content: []const u8, +) ![]const u8 { + try std.fs.cwd().writeFile(.{ .sub_path = schema_file_path, .data = "{}" }); + const temp_file_path = try std.fs.path.join(allocator, &.{ test_dir, config_file_name }); + errdefer allocator.free(temp_file_path); + try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); + return temp_file_path; +} + test "ConfigLoader loads defaults when file not found" { const allocator = std.testing.allocator; @@ -386,8 +403,6 @@ test "config: validation - invalid port" { var context = try schema_helpers.TestContext.init(allocator, "config-invalid-port"); defer context.deinit(); - const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-invalid-port.json" }); - defer allocator.free(temp_file_path); const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-invalid-port.json" }); defer allocator.free(schema_file_path); @@ -401,8 +416,8 @@ test "config: validation - invalid port" { , .{schema_file_path}); defer allocator.free(config_content); - try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); - try std.fs.cwd().writeFile(.{ .sub_path = schema_file_path, .data = "{}" }); + const temp_file_path = try writeConfigWithSchema(allocator, context.test_dir, "test-config-invalid-port.json", schema_file_path, config_content); + defer allocator.free(temp_file_path); const result = ConfigLoader.load(allocator, temp_file_path); try std.testing.expectError(error.InvalidPort, result); @@ -414,8 +429,6 @@ test "config: validation - port zero" { var context = try schema_helpers.TestContext.init(allocator, "config-port-zero"); defer context.deinit(); - const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-port-zero.json" }); - defer allocator.free(temp_file_path); const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-port-zero.json" }); defer allocator.free(schema_file_path); @@ -429,8 +442,8 @@ test "config: validation - port zero" { , .{schema_file_path}); defer allocator.free(config_content); - try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); - try std.fs.cwd().writeFile(.{ .sub_path = schema_file_path, .data = "{}" }); + const temp_file_path = try writeConfigWithSchema(allocator, context.test_dir, "test-config-port-zero.json", schema_file_path, config_content); + defer allocator.free(temp_file_path); const result = ConfigLoader.load(allocator, temp_file_path); try std.testing.expectError(error.InvalidPort, result); @@ -442,8 +455,6 @@ test "config: validation - invalid buffer size" { var context = try schema_helpers.TestContext.init(allocator, "config-invalid-buffer"); defer context.deinit(); - const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-invalid-buffer.json" }); - defer allocator.free(temp_file_path); const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-invalid-buffer.json" }); defer allocator.free(schema_file_path); @@ -457,8 +468,8 @@ test "config: validation - invalid buffer size" { , .{schema_file_path}); defer allocator.free(config_content); - try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); - try std.fs.cwd().writeFile(.{ .sub_path = schema_file_path, .data = "{}" }); + const temp_file_path = try writeConfigWithSchema(allocator, context.test_dir, "test-config-invalid-buffer.json", schema_file_path, config_content); + defer allocator.free(temp_file_path); const result = ConfigLoader.load(allocator, temp_file_path); try std.testing.expectError(error.InvalidBufferSize, result); @@ -470,8 +481,6 @@ test "config: validation - invalid max message size" { var context = try schema_helpers.TestContext.init(allocator, "config-invalid-max-msg"); defer context.deinit(); - const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-invalid-max-message-size.json" }); - defer allocator.free(temp_file_path); const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-invalid-max-message.json" }); defer allocator.free(schema_file_path); @@ -485,8 +494,8 @@ test "config: validation - invalid max message size" { , .{schema_file_path}); defer allocator.free(config_content); - try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); - try std.fs.cwd().writeFile(.{ .sub_path = schema_file_path, .data = "{}" }); + const temp_file_path = try writeConfigWithSchema(allocator, context.test_dir, "test-config-invalid-max-message-size.json", schema_file_path, config_content); + defer allocator.free(temp_file_path); const result = ConfigLoader.load(allocator, temp_file_path); try std.testing.expectError(error.InvalidMaxMessageSize, result); @@ -614,8 +623,6 @@ test "config: round-trip - server config" { var context = try schema_helpers.TestContext.init(allocator, "config-roundtrip-server"); defer context.deinit(); - const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-roundtrip-server.json" }); - defer allocator.free(temp_file_path); const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-roundtrip-server.json" }); defer allocator.free(schema_file_path); @@ -630,8 +637,8 @@ test "config: round-trip - server config" { , .{schema_file_path}); defer allocator.free(config_content); - try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); - try std.fs.cwd().writeFile(.{ .sub_path = schema_file_path, .data = "{}" }); + const temp_file_path = try writeConfigWithSchema(allocator, context.test_dir, "test-config-roundtrip-server.json", schema_file_path, config_content); + defer allocator.free(temp_file_path); var config = try ConfigLoader.load(allocator, temp_file_path); defer config.deinit(); @@ -647,8 +654,6 @@ test "config: round-trip - logging config" { var context = try schema_helpers.TestContext.init(allocator, "config-roundtrip-logging"); defer context.deinit(); - const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-roundtrip-logging.json" }); - defer allocator.free(temp_file_path); const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-roundtrip-logging.json" }); defer allocator.free(schema_file_path); @@ -663,8 +668,8 @@ test "config: round-trip - logging config" { , .{schema_file_path}); defer allocator.free(config_content); - try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); - try std.fs.cwd().writeFile(.{ .sub_path = schema_file_path, .data = "{}" }); + const temp_file_path = try writeConfigWithSchema(allocator, context.test_dir, "test-config-roundtrip-logging.json", schema_file_path, config_content); + defer allocator.free(temp_file_path); var config = try ConfigLoader.load(allocator, temp_file_path); defer config.deinit(); @@ -680,8 +685,6 @@ test "config: round-trip - complete config" { var context = try schema_helpers.TestContext.init(allocator, "config-roundtrip-complete"); defer context.deinit(); - const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-roundtrip-complete.json" }); - defer allocator.free(temp_file_path); const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-roundtrip-complete.json" }); defer allocator.free(schema_file_path); @@ -722,8 +725,8 @@ test "config: round-trip - complete config" { , .{ context.test_dir, schema_file_path }); defer allocator.free(config_content); - try std.fs.cwd().writeFile(.{ .sub_path = temp_file_path, .data = config_content }); - try std.fs.cwd().writeFile(.{ .sub_path = schema_file_path, .data = "{}" }); + const temp_file_path = try writeConfigWithSchema(allocator, context.test_dir, "test-config-roundtrip-complete.json", schema_file_path, config_content); + defer allocator.free(temp_file_path); var config = try ConfigLoader.load(allocator, temp_file_path); defer config.deinit(); diff --git a/src/logging_test.zig b/src/logging_test.zig index 45a5f064..2a8ee8bf 100644 --- a/src/logging_test.zig +++ b/src/logging_test.zig @@ -19,7 +19,13 @@ test "logging: connection events" { // a log entry is written with the connection ID. var gpa = std.heap.GeneralPurposeAllocator(.{}){}; - defer _ = gpa.deinit(); + defer { + const leaked = gpa.deinit(); + if (leaked == .leak) { + std.debug.print("Memory leak detected!\n", .{}); + @panic("Memory leak in init/deinit cycle"); + } + } const allocator = gpa.allocator(); var app: AppTestContext = undefined; @@ -136,13 +142,21 @@ test "logging: connection events" { } test "logging: error details" { - // Error event logging properties + // Message-parsing error logging properties // - // This property test verifies that for any database or message parsing error, - // a log entry is written with error details. + // This property test verifies that for any message parsing error, + // a log entry is written with error details. Because the log sink + // cannot be intercepted directly, the tests exercise the error paths + // (and one null-record lookup) and assert on observable behavior. var gpa = std.heap.GeneralPurposeAllocator(.{}){}; - defer _ = gpa.deinit(); + defer { + const leaked = gpa.deinit(); + if (leaked == .leak) { + std.debug.print("Memory leak detected!\n", .{}); + @panic("Memory leak in init/deinit cycle"); + } + } const allocator = gpa.allocator(); // Initialize components @@ -199,8 +213,9 @@ test "logging: error details" { manager.onClose(&ws); } - // Test 3: Database errors are logged - // Storage engine logs errors internally when operations fail + // Test 3: Missing records return null (not an error) + // Reading an absent document is normal operation, not a database error: + // readDoc returns null and no error is raised. { const tbl_md = app.schema.table("data_table") orelse return error.TableNotFound; // Try to get from non-existent namespace/path @@ -243,7 +258,13 @@ test "logging: level filtering" { // We verify that the logging infrastructure is in place and used correctly. var gpa = std.heap.GeneralPurposeAllocator(.{}){}; - defer _ = gpa.deinit(); + defer { + const leaked = gpa.deinit(); + if (leaked == .leak) { + std.debug.print("Memory leak detected!\n", .{}); + @panic("Memory leak in init/deinit cycle"); + } + } const allocator = gpa.allocator(); // Test 1: Verify different log levels are used appropriately @@ -277,7 +298,13 @@ test "logging: message formatting" { // is text-based with level, scope, and message. var gpa = std.heap.GeneralPurposeAllocator(.{}){}; - defer _ = gpa.deinit(); + defer { + const leaked = gpa.deinit(); + if (leaked == .leak) { + std.debug.print("Memory leak detected!\n", .{}); + @panic("Memory leak in init/deinit cycle"); + } + } const allocator = gpa.allocator(); // Test 1: Verify log messages include required information diff --git a/src/migration_executor_test.zig b/src/migration_executor_test.zig index 83ba5d6f..ab463017 100644 --- a/src/migration_executor_test.zig +++ b/src/migration_executor_test.zig @@ -220,6 +220,34 @@ fn insertSchemaMetaVersion(db: *sqlite.Db, allocator: std.mem.Allocator, version try execSql(db, allocator, sql); } +/// Returns the declared type of `column_name` in `table_name` via PRAGMA table_info, +/// or null if the column is absent. The returned slice is owned by the caller. +fn columnType(db: *sqlite.Db, allocator: std.mem.Allocator, table_name: []const u8, column_name: []const u8) !?[]const u8 { + const pragma_sql = try std.fmt.allocPrint(allocator, "PRAGMA table_info({s})", .{table_name}); + defer allocator.free(pragma_sql); + var stmt = try db.prepareDynamic(pragma_sql); + defer stmt.deinit(); + + const PragmaRow = struct { + cid: i64, + name: []const u8, + type: []const u8, + notnull: i64, + dflt_value: ?[]const u8, + pk: i64, + }; + var iter = try stmt.iteratorAlloc(PragmaRow, allocator, .{}); + while (try iter.nextAlloc(allocator, .{})) |row| { + defer { + allocator.free(row.name); + allocator.free(row.type); + if (row.dflt_value) |dv| allocator.free(dv); + } + if (std.mem.eql(u8, row.name, column_name)) return try allocator.dupe(u8, row.type); + } + return null; +} + // Feature: schema-aware-storage, Property 10: Additive migration preserves existing data // For any database state and additive MigrationPlan (containing only create_table and add_column // changes), after Migration_Executor.execute completes, every row that existed before the migration @@ -292,10 +320,8 @@ test "migration_executor: additive migration preserves existing data" { .{tname}, ); defer allocator.free(check_sql); - const check_sql_z = try allocator.dupeZ(u8, check_sql); - defer allocator.free(check_sql_z); - var stmt = try db.prepareDynamic(check_sql_z); + var stmt = try db.prepareDynamic(check_sql); defer stmt.deinit(); const CheckRow = struct { @@ -384,10 +410,8 @@ test "migration_executor: destructive migration refused when not allowed" { .{tname}, ); defer allocator.free(check_sql); - const check_sql_z = try allocator.dupeZ(u8, check_sql); - defer allocator.free(check_sql_z); - var stmt = try db.prepareDynamic(check_sql_z); + var stmt = try db.prepareDynamic(check_sql); defer stmt.deinit(); const IdRow = struct { id: []const u8 }; @@ -398,38 +422,10 @@ test "migration_executor: destructive migration refused when not allowed" { try std.testing.expectEqualSlices(u8, &zero_doc_id, row.?.id); // Verify DB schema is unchanged: col_a must still be TEXT - const pragma_sql = try std.fmt.allocPrint( - allocator, - "PRAGMA table_info({s})", - .{tname}, - ); - defer allocator.free(pragma_sql); - - var pragma_stmt = try db.prepareDynamic(pragma_sql); - defer pragma_stmt.deinit(); - - const PragmaRow = struct { - cid: i64, - name: []const u8, - type: []const u8, - notnull: i64, - dflt_value: ?[]const u8, - pk: i64, - }; - - var col_a_is_text = false; - var pragma_iter = try pragma_stmt.iteratorAlloc(PragmaRow, allocator, .{}); - while (try pragma_iter.nextAlloc(allocator, .{})) |pragma_row| { - defer { - allocator.free(pragma_row.name); - allocator.free(pragma_row.type); - if (pragma_row.dflt_value) |dv| allocator.free(dv); - } - if (std.mem.eql(u8, pragma_row.name, "col_a") and std.mem.eql(u8, pragma_row.type, "TEXT")) { - col_a_is_text = true; - } - } - try std.testing.expect(col_a_is_text); + const col_type = try columnType(&db, allocator, tname, "col_a"); + defer if (col_type) |ct| allocator.free(ct); + try std.testing.expect(col_type != null); + try std.testing.expectEqualStrings("TEXT", col_type.?); } } } @@ -557,39 +553,9 @@ test "migration_executor: major version bump is refused" { try std.testing.expectError(error.MajorVersionBumpNotAllowed, result); // Verify no changes were applied: column 'extra' should not exist - // Check via PRAGMA table_info - 'extra' column should not be present - const pragma_sql = try std.fmt.allocPrint( - allocator, - "PRAGMA table_info({s})", - .{tname}, - ); - defer allocator.free(pragma_sql); - - var pragma_stmt = try db.prepareDynamic(pragma_sql); - defer pragma_stmt.deinit(); - - const PragmaRow = struct { - cid: i64, - name: []const u8, - type: []const u8, - notnull: i64, - dflt_value: ?[]const u8, - pk: i64, - }; - - var found_extra = false; - var pragma_iter = try pragma_stmt.iteratorAlloc(PragmaRow, allocator, .{}); - while (try pragma_iter.nextAlloc(allocator, .{})) |row| { - defer { - allocator.free(row.name); - allocator.free(row.type); - if (row.dflt_value) |dv| allocator.free(dv); - } - if (std.mem.eql(u8, row.name, "extra")) { - found_extra = true; - } - } - try std.testing.expect(!found_extra); + const extra_type = try columnType(&db, allocator, tname, "extra"); + defer if (extra_type) |et| allocator.free(et); + try std.testing.expect(extra_type == null); } } } diff --git a/src/msgpack_utils_test.zig b/src/msgpack_utils_test.zig index d418e690..8341f4e3 100644 --- a/src/msgpack_utils_test.zig +++ b/src/msgpack_utils_test.zig @@ -78,10 +78,9 @@ test "msgpack_utils: writeMsgPackStr str32 (>65535 bytes)" { // Payloads beyond wire_limits (depth 32, array/map 100k, string/bin/ext 1MB) // must be rejected with the corresponding limit error. fn writeBe32(buf: []u8, offset: usize, value: u32) void { - buf[offset + 0] = @intCast((value >> 24) & 0xff); - buf[offset + 1] = @intCast((value >> 16) & 0xff); - buf[offset + 2] = @intCast((value >> 8) & 0xff); - buf[offset + 3] = @intCast(value & 0xff); + var tmp: [4]u8 align(1) = undefined; + std.mem.writeInt(u32, &tmp, value, .big); + @memcpy(buf[offset .. offset + 4], &tmp); } fn expectDecodeError(allocator: std.mem.Allocator, bytes: []const u8, expected_err: anyerror) !void { @@ -108,28 +107,6 @@ test "msgpack: reject oversized payloads (depth, array, map, string)" { try expectDecodeError(allocator, &depth_bomb, error.MaxDepthExceeded); } - // Array bomb: array32 claiming 100,001 elements (max_array_length = 100,000). - { - const count: u32 = 100001; - const array_bomb = try allocator.alloc(u8, 5 + count); - defer allocator.free(array_bomb); - array_bomb[0] = 0xdd; // array32 - writeBe32(array_bomb, 1, count); - @memset(array_bomb[5..], 0xc0); // nil - try expectDecodeError(allocator, array_bomb, error.ArrayTooLarge); - } - - // Map bomb: map32 claiming 100,001 entries (max_map_size = 100,000). - { - const count: u32 = 100001; - const map_bomb = try allocator.alloc(u8, 5 + 2 * count); - defer allocator.free(map_bomb); - map_bomb[0] = 0xdf; // map32 - writeBe32(map_bomb, 1, count); - @memset(map_bomb[5..], 0xc0); // nil key+value pairs - try expectDecodeError(allocator, map_bomb, error.MapTooLarge); - } - // String bomb: str32 claiming 2 MB (max_string_length = 1 MB). { const str_len: u32 = 2 * 1024 * 1024; @@ -409,6 +386,6 @@ fn verifyPayloadEquality(expected: msgpack.Payload, actual: msgpack.Payload) !vo try verifyPayloadEquality(entry.value_ptr.*, actual_val); } }, - else => {}, + else => return error.UnsupportedRoundTripPayload, } } diff --git a/src/storage_engine_error_test.zig b/src/storage_engine_error_test.zig index c465d8fb..4f9685f6 100644 --- a/src/storage_engine_error_test.zig +++ b/src/storage_engine_error_test.zig @@ -42,7 +42,7 @@ test "storage: write/flush/read round-trip on file-backed engine" { const allocator = testing.allocator; const table = schema_helpers.makeTable("data_table", &.{schema_helpers.makeField("val", .text)}); var ctx: sth.EngineTestContext = undefined; - try sth.setupEngineWithOptions(&ctx, allocator, "storage-error-readonly", table, .{ .in_memory = false }); + try sth.setupEngineWithOptions(&ctx, allocator, "storage-error-roundtrip", table, .{ .in_memory = false }); defer ctx.deinit(); const storage = &ctx.engine; const tbl = try ctx.table("data_table"); @@ -112,6 +112,7 @@ test "storage: error handling concurrent access safety" { } fn runErr(t_ctx: ThreadContext, table_index: usize) !void { const record = try sth.readDoc(t_ctx.allocator, t_ctx.storage, table_index, 1, 1); + try testing.expect(record != null); defer if (record) |r| r.deinit(t_ctx.allocator); } }.run; @@ -136,12 +137,12 @@ test "storage: write/flush/read round-trip with empty value" { const storage = &ctx.engine; const tbl = try ctx.table("data_table"); - try ctx.insertText("data_table", 1, 1, "val", "value"); + try ctx.insertText("data_table", 1, 1, "val", ""); try storage.flushPendingWrites(); { - const record = try tbl.readDoc(allocator, 1, 1); - defer if (record) |r| r.deinit(allocator); - try testing.expect(record != null); + var doc = try tbl.getOne(allocator, 1, 1); + defer doc.deinit(); + _ = try doc.expectFieldString("val", ""); } } test "storage: error handling large values" { diff --git a/src/storage_engine_stability_test.zig b/src/storage_engine_stability_test.zig index 29de1863..5bc3e9e2 100644 --- a/src/storage_engine_stability_test.zig +++ b/src/storage_engine_stability_test.zig @@ -7,14 +7,13 @@ const sth = @import("storage_engine_test_helpers.zig"); const testing = std.testing; // This property test verifies that the storage engine stays stable under -// concurrent insert/read/delete operations while errors are injected: -// no panics or crashes occur, and the engine keeps operating. +// concurrent insert/read/delete operations: no panics or crashes occur, and +// the engine keeps operating. // -// We test various error scenarios to ensure the server never crashes: -// - Multiple concurrent operations during errors -// - Rapid error conditions -// - Error recovery and retry logic -// - Resource cleanup after errors +// The workers run a mix of insert/read/delete operations concurrently; +// operation errors are tolerated via the existing error handling so that a +// transient failure cannot crash a worker. The test passes if all threads +// complete and a final flush succeeds. // ─── Tests ─────────────────────────────────────────────────────────────────── diff --git a/src/storage_engine_test.zig b/src/storage_engine_test.zig index 61a043d2..d6e1e20f 100644 --- a/src/storage_engine_test.zig +++ b/src/storage_engine_test.zig @@ -1136,29 +1136,6 @@ test "storage: engine init rejects file path as data dir" { try testing.expectError(error.NotDir, result); } -test "storage: engine init creates database file" { - const allocator = testing.allocator; - var ms: sth.MemoryStrategy = undefined; - try ms.init(allocator); - defer std.debug.assert(ms.deinit() == .ok); - - var context = try sth.TestContext.init(allocator, "storage-init-valid"); - defer context.deinit(); - const test_dir = context.test_dir; - - var sm = try sth.createDummySchema(allocator); - defer sm.deinit(); - var engine: StorageEngine = undefined; - try engine.init(allocator, &ms, test_dir, &sm, .{}, .{ .in_memory = false, .reader_pool_size = 1 }, null, null); - defer engine.deinit(); - - // Verify database file was created - const db_path = try std.fs.path.join(allocator, &.{ test_dir, "zyncbase.db" }); - defer allocator.free(db_path); - const db_file = try std.fs.cwd().openFile(db_path, .{}); - db_file.close(); -} - // Storage engine thread safety properties test "storage: thread-safe engine access" { const allocator = testing.allocator; @@ -1217,14 +1194,20 @@ test "storage: thread-safe engine access" { // Spawn write threads var write_threads: [num_threads / 2]std.Thread = undefined; + var write_spawned: usize = 0; + errdefer for (write_threads[0..write_spawned]) |thread| thread.join(); for (&write_threads, 0..) |*thread, i| { thread.* = try std.Thread.spawn(.{}, WriteThread.run, .{ ThreadContext{ .ctx = &ctx, .result = &write_results[i] }, i }); + write_spawned += 1; } // Spawn read threads var read_threads: [num_threads / 2]std.Thread = undefined; const test_table = try ctx.table("test"); + var read_spawned: usize = 0; + errdefer for (read_threads[0..read_spawned]) |thread| thread.join(); for (&read_threads, 0..) |*thread, i| { thread.* = try std.Thread.spawn(.{}, ReadThread.run, .{ engine, test_table.metadata.index, i, &read_results[i] }); + read_spawned += 1; } // Wait for all threads for (write_threads) |thread| { @@ -1267,7 +1250,7 @@ test "storage: connection pool reuse and release" { const test_table = try ctx.table("test"); while (i < num_operations) : (i += 1) { const key: u128 = if (i % 2 == 0) 1 else 2; - const record = try test_table.readDoc(testing.allocator, key, 1); + const record = try test_table.readDoc(allocator, key, 1); defer if (record) |r| r.deinit(allocator); try testing.expect(record != null); } @@ -1350,7 +1333,7 @@ test "storage: insert/delete inverse consistency" { } } -test "storage: transaction isolation and consistency" { +test "storage: batched writes commit consistently" { const allocator = testing.allocator; var fields_arr = [_]sth.Field{schema_helpers.makeField("val", .text)}; const table = schema_helpers.makeTable("test", &fields_arr); @@ -1400,7 +1383,7 @@ test "storage: transaction isolation and consistency" { _ = try sth.expectFieldString(record_n3.?, test_table.metadata, "val", "new3"); } -test "storage: concurrent batch processing" { +test "storage: multi-batch writes all persist" { const allocator = testing.allocator; var fields_arr = [_]sth.Field{schema_helpers.makeField("val", .text)}; const table = schema_helpers.makeTable("test", &fields_arr); @@ -1490,7 +1473,7 @@ test "storage: data persistence across restarts" { { var ctx: sth.EngineTestContext = undefined; try sth.setupEngineWithDir(&ctx, allocator, test_dir, table, .{ .in_memory = false }); - defer ctx.deinit(); + defer ctx.deinitNoCleanup(); const test_table = try ctx.table("test"); const record = try test_table.readDoc(allocator, 1, 1); @@ -1529,7 +1512,7 @@ test "storage: schema update integrity" { var ctx: sth.EngineTestContext = undefined; try sth.setupEngineWithDir(&ctx, allocator, test_dir, table_v2, .{ .in_memory = false }); - defer ctx.deinit(); + defer ctx.deinitNoCleanup(); const test_table = try ctx.table("test"); @@ -1546,6 +1529,7 @@ test "storage: schema update integrity" { const record2 = try test_table.readDoc(allocator, 2, 1); defer if (record2) |r| r.deinit(allocator); try testing.expect(record2 != null); + _ = try sth.expectFieldInt(record2.?, test_table.metadata, "val2", 42); } } @@ -1566,6 +1550,7 @@ test "storage: random operations fuzzing" { const rand = prng.random(); const items_index = ctx.tableIndex("items"); + const test_table = try ctx.table("items"); var i: usize = 0; while (i < 200) : (i += 1) { const op = rand.intRangeAtMost(u8, 0, 3); @@ -1584,7 +1569,6 @@ test "storage: random operations fuzzing" { }, 2 => { // Query - const test_table = try ctx.table("items"); const record = try test_table.readDoc(allocator, id, ns); if (record) |r| r.deinit(allocator); }, diff --git a/src/test_all.zig b/src/test_all.zig index f97e1d9e..d6bce444 100644 --- a/src/test_all.zig +++ b/src/test_all.zig @@ -62,12 +62,12 @@ test { _ = @import("memory/lock_free_cache_test.zig"); _ = @import("memory/lock_free_cache_leak_test.zig"); _ = @import("memory/strategy_test.zig"); + _ = @import("config/loader_test.zig"); _ = @import("uwebsockets_wrapper_test.zig"); _ = @import("storage_engine_test.zig"); _ = @import("storage_engine_sql_test.zig"); _ = @import("checkpoint_worker_test.zig"); - _ = @import("config/loader_test.zig"); _ = @import("message_handler_test.zig"); _ = @import("store_service_test.zig"); _ = @import("migration_executor_test.zig"); From 84444e07693e86b898ed2354df4fa813bf15191d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20Dokumac=C4=B1?= Date: Sun, 2 Aug 2026 13:20:14 +0200 Subject: [PATCH 5/8] chore: adjust perf test thresholds --- src/storage_engine/read_worker_perf_test.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/storage_engine/read_worker_perf_test.zig b/src/storage_engine/read_worker_perf_test.zig index 0c9421b8..3067c7d5 100644 --- a/src/storage_engine/read_worker_perf_test.zig +++ b/src/storage_engine/read_worker_perf_test.zig @@ -548,5 +548,5 @@ test "ReadWorker: selectQuery throughput" { try runPrioritySweep(allocator, worker, table_metadata, priority_field_index, 25, 50, iterations, if (is_tsan) 5.4 else if (is_debug) 1.8 else 0.6); // Sweep: rows=100, priority < 50 - try runPrioritySweep(allocator, worker, table_metadata, priority_field_index, 50, 100, iterations, if (is_tsan) 8.1 else if (is_debug) 2.7 else 0.9); + try runPrioritySweep(allocator, worker, table_metadata, priority_field_index, 50, 100, iterations, if (is_tsan) 9.0 else if (is_debug) 3.0 else 1.0); } From 546d536eca0b2ea98b08d5c5c3a4530906a471f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20Dokumac=C4=B1?= Date: Sun, 2 Aug 2026 13:54:18 +0200 Subject: [PATCH 6/8] chore: address review comments --- src/config/loader_test.zig | 72 +++++++++++++++++++++++++++++++------- 1 file changed, 60 insertions(+), 12 deletions(-) diff --git a/src/config/loader_test.zig b/src/config/loader_test.zig index a9a2a07d..2b7853a3 100644 --- a/src/config/loader_test.zig +++ b/src/config/loader_test.zig @@ -261,15 +261,35 @@ test "config: env var substitution" { const allocator = std.testing.allocator; // Set up test environment variables using C setenv + const prev_test_port = c.getenv("TEST_PORT"); + const prev_test_host = c.getenv("TEST_HOST"); + const prev_test_jwt_secret = c.getenv("TEST_JWT_SECRET"); + const prev_test_data_dir = c.getenv("TEST_DATA_DIR"); _ = c.setenv("TEST_PORT", "8080", 1); _ = c.setenv("TEST_HOST", "192.168.1.1", 1); _ = c.setenv("TEST_JWT_SECRET", "test-secret-key", 1); _ = c.setenv("TEST_DATA_DIR", "/tmp/test-isolated-dir", 1); defer { - _ = c.unsetenv("TEST_PORT"); - _ = c.unsetenv("TEST_HOST"); - _ = c.unsetenv("TEST_JWT_SECRET"); - _ = c.unsetenv("TEST_DATA_DIR"); + if (prev_test_port) |v| { + _ = c.setenv("TEST_PORT", v, 1); + } else { + _ = c.unsetenv("TEST_PORT"); + } + if (prev_test_host) |v| { + _ = c.setenv("TEST_HOST", v, 1); + } else { + _ = c.unsetenv("TEST_HOST"); + } + if (prev_test_jwt_secret) |v| { + _ = c.setenv("TEST_JWT_SECRET", v, 1); + } else { + _ = c.unsetenv("TEST_JWT_SECRET"); + } + if (prev_test_data_dir) |v| { + _ = c.setenv("TEST_DATA_DIR", v, 1); + } else { + _ = c.unsetenv("TEST_DATA_DIR"); + } } var context = try schema_helpers.TestContext.init(allocator, "config-env-vars"); @@ -316,7 +336,13 @@ test "config: env var substitution - missing variable keeps original" { const allocator = std.testing.allocator; // Ensure the variable doesn't exist + const prev_nonexistent_var = c.getenv("NONEXISTENT_VAR"); _ = c.unsetenv("NONEXISTENT_VAR"); + defer { + if (prev_nonexistent_var) |v| { + _ = c.setenv("NONEXISTENT_VAR", v, 1); + } + } var context = try schema_helpers.TestContext.init(allocator, "config-missing-env"); defer context.deinit(); @@ -352,13 +378,28 @@ test "config: env var substitution - multiple variables" { const allocator = std.testing.allocator; // Set up multiple test environment variables + const prev_test_origin_1 = c.getenv("TEST_ORIGIN_1"); + const prev_test_origin_2 = c.getenv("TEST_ORIGIN_2"); + const prev_test_rate_limit = c.getenv("TEST_RATE_LIMIT"); _ = c.setenv("TEST_ORIGIN_1", "https://example.com", 1); _ = c.setenv("TEST_ORIGIN_2", "https://app.example.com", 1); _ = c.setenv("TEST_RATE_LIMIT", "200", 1); defer { - _ = c.unsetenv("TEST_ORIGIN_1"); - _ = c.unsetenv("TEST_ORIGIN_2"); - _ = c.unsetenv("TEST_RATE_LIMIT"); + if (prev_test_origin_1) |v| { + _ = c.setenv("TEST_ORIGIN_1", v, 1); + } else { + _ = c.unsetenv("TEST_ORIGIN_1"); + } + if (prev_test_origin_2) |v| { + _ = c.setenv("TEST_ORIGIN_2", v, 1); + } else { + _ = c.unsetenv("TEST_ORIGIN_2"); + } + if (prev_test_rate_limit) |v| { + _ = c.setenv("TEST_RATE_LIMIT", v, 1); + } else { + _ = c.unsetenv("TEST_RATE_LIMIT"); + } } var context = try schema_helpers.TestContext.init(allocator, "config-multiple-env"); @@ -615,9 +656,9 @@ test "config: file existence validation - valid auth rules file" { } // Authorization selection properties -// Invariant: Configuration round-trip -// For any valid configuration, serializing then parsing should produce an equivalent configuration. -test "config: round-trip - server config" { +// Invariant: Configuration loading +// For any valid configuration, loading valid configuration produces the expected equivalent configuration. +test "config: load - server config" { const allocator = std.testing.allocator; var context = try schema_helpers.TestContext.init(allocator, "config-roundtrip-server"); @@ -648,7 +689,7 @@ test "config: round-trip - server config" { try std.testing.expectEqualStrings("127.0.0.1", config.server.host); } -test "config: round-trip - logging config" { +test "config: load - logging config" { const allocator = std.testing.allocator; var context = try schema_helpers.TestContext.init(allocator, "config-roundtrip-logging"); @@ -679,7 +720,7 @@ test "config: round-trip - logging config" { try std.testing.expectEqual(Config.LoggingConfig.LogFormat.text, config.logging.format); } -test "config: round-trip - complete config" { +test "config: load - complete config" { const allocator = std.testing.allocator; var context = try schema_helpers.TestContext.init(allocator, "config-roundtrip-complete"); @@ -738,10 +779,17 @@ test "config: round-trip - complete config" { try std.testing.expect(config.authentication.jwt_secret != null); try std.testing.expectEqualStrings("my-secret-key", config.authentication.jwt_secret.?); try std.testing.expectEqualStrings("HS512", config.authentication.jwt_algorithm); + try std.testing.expect(config.authentication.jwt_issuer != null); + try std.testing.expectEqualStrings("zyncbase", config.authentication.jwt_issuer.?); + try std.testing.expect(config.authentication.jwt_audience != null); + try std.testing.expectEqualStrings("api", config.authentication.jwt_audience.?); try std.testing.expectEqual(@as(usize, 1), config.security.allowed_origins.len); try std.testing.expectEqualStrings("https://example.com", config.security.allowed_origins[0]); try std.testing.expectEqual(false, config.security.allow_localhost); + try std.testing.expectEqual(@as(u32, 200), config.security.max_messages_per_second); + try std.testing.expectEqual(@as(u32, 20), config.security.max_connections); + try std.testing.expectEqual(@as(usize, 2097152), config.security.max_message_size); try std.testing.expectEqual(Config.LoggingConfig.LogLevel.debug, config.logging.level); try std.testing.expectEqual(Config.LoggingConfig.LogFormat.text, config.logging.format); From a110f0d0f6ec50dda0c89949064ecdec55e4bd1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20Dokumac=C4=B1?= Date: Sun, 2 Aug 2026 14:27:51 +0200 Subject: [PATCH 7/8] chore: address review comments --- src/config/loader_test.zig | 95 ++++++++++++-------------------------- 1 file changed, 30 insertions(+), 65 deletions(-) diff --git a/src/config/loader_test.zig b/src/config/loader_test.zig index 2b7853a3..d19bca49 100644 --- a/src/config/loader_test.zig +++ b/src/config/loader_test.zig @@ -25,6 +25,13 @@ fn writeConfigWithSchema( return temp_file_path; } +/// Duplicates an optional getenv result into allocator-owned, sentinel-terminated +/// storage so later setenv/unsetenv calls cannot invalidate it. Returns null for +/// unset variables. The caller owns the returned copy. +fn dupeEnvZ(allocator: std.mem.Allocator, value: ?[*:0]const u8) !?[:0]u8 { + return if (value) |v| try allocator.dupeZ(u8, std.mem.span(v)) else null; +} + test "ConfigLoader loads defaults when file not found" { const allocator = std.testing.allocator; @@ -260,15 +267,24 @@ test "ConfigLoader parses inline schema configuration" { test "config: env var substitution" { const allocator = std.testing.allocator; + var context = try schema_helpers.TestContext.init(allocator, "config-env-vars"); + defer context.deinit(); + // Set up test environment variables using C setenv - const prev_test_port = c.getenv("TEST_PORT"); - const prev_test_host = c.getenv("TEST_HOST"); - const prev_test_jwt_secret = c.getenv("TEST_JWT_SECRET"); - const prev_test_data_dir = c.getenv("TEST_DATA_DIR"); + const prev_test_port = try dupeEnvZ(allocator, c.getenv("TEST_PORT")); + defer if (prev_test_port) |v| allocator.free(v); + const prev_test_host = try dupeEnvZ(allocator, c.getenv("TEST_HOST")); + defer if (prev_test_host) |v| allocator.free(v); + const prev_test_jwt_secret = try dupeEnvZ(allocator, c.getenv("TEST_JWT_SECRET")); + defer if (prev_test_jwt_secret) |v| allocator.free(v); + const prev_test_data_dir = try dupeEnvZ(allocator, c.getenv("TEST_DATA_DIR")); + defer if (prev_test_data_dir) |v| allocator.free(v); _ = c.setenv("TEST_PORT", "8080", 1); _ = c.setenv("TEST_HOST", "192.168.1.1", 1); _ = c.setenv("TEST_JWT_SECRET", "test-secret-key", 1); - _ = c.setenv("TEST_DATA_DIR", "/tmp/test-isolated-dir", 1); + const test_data_dir_z = try allocator.dupeZ(u8, context.test_dir); + defer allocator.free(test_data_dir_z); + _ = c.setenv("TEST_DATA_DIR", test_data_dir_z, 1); defer { if (prev_test_port) |v| { _ = c.setenv("TEST_PORT", v, 1); @@ -292,9 +308,6 @@ test "config: env var substitution" { } } - var context = try schema_helpers.TestContext.init(allocator, "config-env-vars"); - defer context.deinit(); - const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-env-vars.json" }); defer allocator.free(temp_file_path); const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-env.json" }); @@ -329,14 +342,15 @@ test "config: env var substitution" { try std.testing.expectEqualStrings("192.168.1.1", config.server.host); try std.testing.expect(config.authentication.jwt_secret != null); try std.testing.expectEqualStrings("test-secret-key", config.authentication.jwt_secret.?); - try std.testing.expectEqualStrings("/tmp/test-isolated-dir", config.data_dir); + try std.testing.expectEqualStrings(context.test_dir, config.data_dir); } test "config: env var substitution - missing variable keeps original" { const allocator = std.testing.allocator; // Ensure the variable doesn't exist - const prev_nonexistent_var = c.getenv("NONEXISTENT_VAR"); + const prev_nonexistent_var = try dupeEnvZ(allocator, c.getenv("NONEXISTENT_VAR")); + defer if (prev_nonexistent_var) |v| allocator.free(v); _ = c.unsetenv("NONEXISTENT_VAR"); defer { if (prev_nonexistent_var) |v| { @@ -378,9 +392,12 @@ test "config: env var substitution - multiple variables" { const allocator = std.testing.allocator; // Set up multiple test environment variables - const prev_test_origin_1 = c.getenv("TEST_ORIGIN_1"); - const prev_test_origin_2 = c.getenv("TEST_ORIGIN_2"); - const prev_test_rate_limit = c.getenv("TEST_RATE_LIMIT"); + const prev_test_origin_1 = try dupeEnvZ(allocator, c.getenv("TEST_ORIGIN_1")); + defer if (prev_test_origin_1) |v| allocator.free(v); + const prev_test_origin_2 = try dupeEnvZ(allocator, c.getenv("TEST_ORIGIN_2")); + defer if (prev_test_origin_2) |v| allocator.free(v); + const prev_test_rate_limit = try dupeEnvZ(allocator, c.getenv("TEST_RATE_LIMIT")); + defer if (prev_test_rate_limit) |v| allocator.free(v); _ = c.setenv("TEST_ORIGIN_1", "https://example.com", 1); _ = c.setenv("TEST_ORIGIN_2", "https://app.example.com", 1); _ = c.setenv("TEST_RATE_LIMIT", "200", 1); @@ -438,32 +455,6 @@ test "config: env var substitution - multiple variables" { // Server configuration properties // Invariant: Configuration validation // For any configuration, validation should catch invalid values and return descriptive errors. -test "config: validation - invalid port" { - const allocator = std.testing.allocator; - - var context = try schema_helpers.TestContext.init(allocator, "config-invalid-port"); - defer context.deinit(); - - const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-invalid-port.json" }); - defer allocator.free(schema_file_path); - - const config_content = try std.fmt.allocPrint(allocator, - \\{{ - \\ "server": {{ - \\ "port": 70000 - \\ }}, - \\ "schema": "{s}" - \\}} - , .{schema_file_path}); - defer allocator.free(config_content); - - const temp_file_path = try writeConfigWithSchema(allocator, context.test_dir, "test-config-invalid-port.json", schema_file_path, config_content); - defer allocator.free(temp_file_path); - - const result = ConfigLoader.load(allocator, temp_file_path); - try std.testing.expectError(error.InvalidPort, result); -} - test "config: validation - port zero" { const allocator = std.testing.allocator; @@ -490,32 +481,6 @@ test "config: validation - port zero" { try std.testing.expectError(error.InvalidPort, result); } -test "config: validation - invalid buffer size" { - const allocator = std.testing.allocator; - - var context = try schema_helpers.TestContext.init(allocator, "config-invalid-buffer"); - defer context.deinit(); - - const schema_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-schema-invalid-buffer.json" }); - defer allocator.free(schema_file_path); - - const config_content = try std.fmt.allocPrint(allocator, - \\{{ - \\ "performance": {{ - \\ "messageBufferSize": 0 - \\ }}, - \\ "schema": "{s}" - \\}} - , .{schema_file_path}); - defer allocator.free(config_content); - - const temp_file_path = try writeConfigWithSchema(allocator, context.test_dir, "test-config-invalid-buffer.json", schema_file_path, config_content); - defer allocator.free(temp_file_path); - - const result = ConfigLoader.load(allocator, temp_file_path); - try std.testing.expectError(error.InvalidBufferSize, result); -} - test "config: validation - invalid max message size" { const allocator = std.testing.allocator; From d68f85dcf93f39ae44ca67c77fad745bf3f14eff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20Dokumac=C4=B1?= Date: Sun, 2 Aug 2026 14:45:28 +0200 Subject: [PATCH 8/8] chore: address review comments --- src/config/loader_test.zig | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/config/loader_test.zig b/src/config/loader_test.zig index d19bca49..84880931 100644 --- a/src/config/loader_test.zig +++ b/src/config/loader_test.zig @@ -279,12 +279,6 @@ test "config: env var substitution" { defer if (prev_test_jwt_secret) |v| allocator.free(v); const prev_test_data_dir = try dupeEnvZ(allocator, c.getenv("TEST_DATA_DIR")); defer if (prev_test_data_dir) |v| allocator.free(v); - _ = c.setenv("TEST_PORT", "8080", 1); - _ = c.setenv("TEST_HOST", "192.168.1.1", 1); - _ = c.setenv("TEST_JWT_SECRET", "test-secret-key", 1); - const test_data_dir_z = try allocator.dupeZ(u8, context.test_dir); - defer allocator.free(test_data_dir_z); - _ = c.setenv("TEST_DATA_DIR", test_data_dir_z, 1); defer { if (prev_test_port) |v| { _ = c.setenv("TEST_PORT", v, 1); @@ -307,6 +301,12 @@ test "config: env var substitution" { _ = c.unsetenv("TEST_DATA_DIR"); } } + _ = c.setenv("TEST_PORT", "8080", 1); + _ = c.setenv("TEST_HOST", "192.168.1.1", 1); + _ = c.setenv("TEST_JWT_SECRET", "test-secret-key", 1); + const test_data_dir_z = try allocator.dupeZ(u8, context.test_dir); + defer allocator.free(test_data_dir_z); + _ = c.setenv("TEST_DATA_DIR", test_data_dir_z, 1); const temp_file_path = try std.fs.path.join(allocator, &.{ context.test_dir, "test-config-env-vars.json" }); defer allocator.free(temp_file_path); @@ -713,6 +713,7 @@ test "config: load - complete config" { \\ "allowLocalhost": false, \\ "maxMessagesPerSecond": 200, \\ "maxConnections": 20, + \\ "violationThreshold": 5, \\ "maxMessageSize": 2097152 \\ }}, \\ "logging": {{ @@ -723,7 +724,8 @@ test "config: load - complete config" { \\ "messageBufferSize": 2000, \\ "batchWrites": false, \\ "batchSize": 50, - \\ "batchTimeout": 20 + \\ "batchTimeout": 20, + \\ "statementCacheSize": 250 \\ }}, \\ "dataDir": "{s}", \\ "schema": "{s}" @@ -754,6 +756,7 @@ test "config: load - complete config" { try std.testing.expectEqual(false, config.security.allow_localhost); try std.testing.expectEqual(@as(u32, 200), config.security.max_messages_per_second); try std.testing.expectEqual(@as(u32, 20), config.security.max_connections); + try std.testing.expectEqual(@as(u32, 5), config.security.violation_threshold); try std.testing.expectEqual(@as(usize, 2097152), config.security.max_message_size); try std.testing.expectEqual(Config.LoggingConfig.LogLevel.debug, config.logging.level); @@ -763,6 +766,7 @@ test "config: load - complete config" { try std.testing.expectEqual(false, config.performance.batch_writes); try std.testing.expectEqual(@as(usize, 50), config.performance.batch_size); try std.testing.expectEqual(@as(u32, 20), config.performance.batch_timeout); + try std.testing.expectEqual(@as(usize, 250), config.performance.statement_cache_size); try std.testing.expectEqualStrings(context.test_dir, config.data_dir); }