diff --git a/scripts/unused_pub_detector.js b/scripts/unused_pub_detector.js index ea4ba465..370f9fcd 100644 --- a/scripts/unused_pub_detector.js +++ b/scripts/unused_pub_detector.js @@ -3,7 +3,34 @@ 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$/; +// 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/contest_helpers.zig", false], + ["src/latest_runner.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_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..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; @@ -162,6 +163,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" { @@ -312,3 +316,164 @@ 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; + + // 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 (in-memory WAL reports 0 bytes) + 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); + + // 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" { + 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); + + // 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" { + 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 - failure counter starts at zero" { + 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: 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); + + try testing.expect(initial_failures == 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. + // 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); +} 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..84880931 100644 --- a/src/config/loader_test.zig +++ b/src/config/loader_test.zig @@ -1,9 +1,37 @@ 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; +/// 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; +} + +/// 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; @@ -232,3 +260,513 @@ 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; + + 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 = 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); + defer { + 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"); + } + } + _ = 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); + 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(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 = 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| { + _ = c.setenv("NONEXISTENT_VAR", v, 1); + } + } + + 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 + 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); + defer { + 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"); + 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 - port zero" { + const allocator = std.testing.allocator; + + var context = try schema_helpers.TestContext.init(allocator, "config-port-zero"); + defer context.deinit(); + + 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); + + 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); +} + +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 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); + + 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); +} + +// 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 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"); + defer context.deinit(); + + 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); + + 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(); + + // 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: load - logging config" { + const allocator = std.testing.allocator; + + var context = try schema_helpers.TestContext.init(allocator, "config-roundtrip-logging"); + defer context.deinit(); + + 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); + + 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(); + + // 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: load - complete config" { + const allocator = std.testing.allocator; + + var context = try schema_helpers.TestContext.init(allocator, "config-roundtrip-complete"); + defer context.deinit(); + + 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, + \\ "violationThreshold": 5, + \\ "maxMessageSize": 2097152 + \\ }}, + \\ "logging": {{ + \\ "level": "debug", + \\ "format": "text" + \\ }}, + \\ "performance": {{ + \\ "messageBufferSize": 2000, + \\ "batchWrites": false, + \\ "batchSize": 50, + \\ "batchTimeout": 20, + \\ "statementCacheSize": 250 + \\ }}, + \\ "dataDir": "{s}", + \\ "schema": "{s}" + \\}} + , .{ context.test_dir, schema_file_path }); + defer allocator.free(config_content); + + 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(); + + // 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.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(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); + 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.expectEqual(@as(usize, 250), config.performance.statement_cache_size); + + try std.testing.expectEqualStrings(context.test_dir, config.data_dir); +} diff --git a/src/logging_property_test.zig b/src/logging_property_test.zig deleted file mode 100644 index af22b76d..00000000 --- a/src/logging_property_test.zig +++ /dev/null @@ -1,566 +0,0 @@ -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 - // - // This property test verifies that for any client connection or disconnection, - // a log entry is written with the connection ID. - - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; - defer _ = gpa.deinit(); - const allocator = gpa.allocator(); - - var app: AppTestContext = undefined; - try app.init(allocator, "logging-conn", &.{ - .{ .name = "_dummy", .fields = &.{"val"} }, - }); - defer app.deinit(); - - const manager = &app.connection_manager; - const memory_strategy = &app.memory_strategy; - - // Test 1: Connection open logs connection ID - // Note: We can't easily intercept std.log in tests, but we can verify - // the behavior by checking that onOpen completes successfully - // and the connection is registered - { - // Create a mock WebSocket (we'll use a stub) - // Create a mock WebSocket - var ws = createMockWebSocket(memory_strategy.generalAllocator()); - - // Handle open - this should log "WebSocket connection opened: id={}" - try manager.onOpen(&ws); - - // Verify connection was registered - const conn_id = ws.getConnId(); - const conn_state = try manager.acquireConnection(conn_id); - defer if (conn_state.release()) memory_strategy.releaseConnection(conn_state); - try testing.expectEqual(conn_id, conn_state.id); - - // Clean up - manager.onClose(&ws); - } - - // Test 2: Connection close logs connection ID - { - var ws = createMockWebSocket(memory_strategy.generalAllocator()); - - // Open connection first - try manager.onOpen(&ws); - const conn_id = ws.getConnId(); - - // Close connection - this should log "WebSocket connection closed: id={}, code={}, message={s}" - manager.onClose(&ws); - - // Verify connection was removed - const result = manager.acquireConnection(conn_id); - try testing.expectError(error.ConnectionNotFound, result); - } - - // Test 3: Multiple connections log unique IDs - { - const num_connections = 10; - var connections: [num_connections]WebSocket = undefined; - - // Open all connections - for (&connections) |*ws| { - ws.* = createMockWebSocket(memory_strategy.generalAllocator()); - try manager.onOpen(ws); - } - - // Verify all have unique IDs - var seen_ids = std.AutoHashMap(u64, void).init(allocator); - defer seen_ids.deinit(); - - for (&connections) |*ws| { - const conn_id = ws.getConnId(); - try testing.expect(!seen_ids.contains(conn_id)); - try seen_ids.put(conn_id, {}); - } - - // Close all connections - for (&connections) |*ws| { - manager.onClose(ws); - } - } - - // 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 { - manager: *ConnectionManager, - allocator: std.mem.Allocator, - iterations: usize, - }; - - const worker = struct { - fn run(ctx: *ThreadContext) void { - var i: usize = 0; - while (i < ctx.iterations) : (i += 1) { - var ws = createMockWebSocket(ctx.allocator); - - // Open and close connection - ctx.manager.onOpen(&ws) catch unreachable; // zwanzig-disable-line: swallowed-error - ctx.manager.onClose(&ws); - } - } - }.run; - - var contexts: [4]ThreadContext = undefined; - var threads: [4]std.Thread = undefined; - - for (&contexts, 0..) |*ctx, idx| { - ctx.* = .{ - .manager = manager, - .allocator = memory_strategy.generalAllocator(), - .iterations = 25, - }; - threads[idx] = try std.Thread.spawn(.{}, worker, .{ctx}); - } - - for (threads) |thread| { - thread.join(); - } - } -} - -test "logging: error details" { - // Error event logging properties - // - // This property test verifies that for any database or message parsing error, - // a log entry is written with error details. - - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; - defer _ = gpa.deinit(); - const allocator = gpa.allocator(); - - // Initialize components - var app: AppTestContext = undefined; - try app.init(allocator, "logging-messages", &.{ - .{ .name = "_dummy", .fields = &.{"val"} }, - .{ .name = "data_table", .fields = &.{"val"} }, - }); - defer app.deinit(); - - const manager = &app.connection_manager; - const storage_engine = &app.storage_engine; - const memory_strategy = &app.memory_strategy; - - // Test 1: Message parsing errors are logged - // We can't easily intercept logs, but we can verify the error path is taken - { - var ws = createMockWebSocket(memory_strategy.generalAllocator()); - ws.user_data = &ws; - - // Open connection - try manager.onOpen(&ws); - - // Send invalid message (not MessagePack) - const invalid_msg = "not valid messagepack"; - // This should log: "Failed to parse message from connection {}: {}" - // The error is caught and logged, but doesn't propagate - manager.onMessage(&ws, invalid_msg, .binary); - - // Clean up - manager.onClose(&ws); - } - - // Test 2: Missing required fields logs error - { - var ws = createMockWebSocket(memory_strategy.generalAllocator()); - ws.user_data = &ws; - - try manager.onOpen(&ws); - - // Create message with missing fields (id, namespace, path, value) - var buf = std.ArrayListUnmanaged(u8).empty; - defer buf.deinit(allocator); - const writer = buf.writer(allocator); - try buf.append(allocator, 0x81); // fixmap(1) - try msgpack_helpers.writeMsgPackStr(writer, "type"); - try msgpack_helpers.writeMsgPackStr(writer, "StoreSet"); - - const incomplete_msg = buf.items; - - // This should log: "Failed to extract message info from connection {}: {}" - manager.onMessage(&ws, incomplete_msg, .binary); - - manager.onClose(&ws); - } - - // Test 3: Database errors are logged - // Storage engine logs errors internally when operations fail - { - const tbl_md = app.schema.table("data_table") orelse return error.TableNotFound; - // Try to get from non-existent namespace/path - const record = try sth.readDoc(testing.allocator, storage_engine, tbl_md.index, 1, 1); - defer if (record) |r| r.deinit(testing.allocator); - try testing.expect(record == null); - } - - // Test 4: Multiple error types are logged - { - var ws = createMockWebSocket(memory_strategy.generalAllocator()); - ws.user_data = &ws; - - try manager.onOpen(&ws); - - // Test various error conditions - const test_cases = [_][]const u8{ - "invalid", - "{}", - "{\"type\":\"Unknown\"}", - }; - - for (test_cases) |test_msg| { - manager.onMessage(&ws, test_msg, .binary); - } - - // Also test an empty map - const empty_map = &[_]u8{0x80}; - manager.onMessage(&ws, empty_map, .binary); - - manager.onClose(&ws); - } -} - -test "logging: level filtering" { - // This property test verifies that for any log message, - // it is only written if its level meets or exceeds the configured log level. - // - // Note: Zig's std.log respects the log level at compile time and runtime. - // We verify that the logging infrastructure is in place and used correctly. - - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; - defer _ = gpa.deinit(); - const allocator = gpa.allocator(); - - // 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(); - - // Trigger different log levels - var ws = createMockWebSocket(memory_strategy.generalAllocator()); - - // Info level: connection open - try manager.onOpen(&ws); - - // Warn level: invalid message - 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); - } -} - -test "logging: message formatting" { - // This property test verifies that for any log message, - // it is formatted according to the configured format (JSON or text). - // - // Note: Zig's std.log uses a consistent format. The actual format - // can be customized via the log handler, but the default format - // is text-based with level, scope, and message. - - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; - defer _ = gpa.deinit(); - const allocator = gpa.allocator(); - - // 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(); - - // Trigger various log messages - var ws = createMockWebSocket(memory_strategy.generalAllocator()); - - // Connection logging includes connection ID - try manager.onOpen(&ws); - const conn_id = ws.getConnId(); - try testing.expect(conn_id > 0); - - // Error logging includes error details - 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); - } - - // 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(); - - // 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); - } - - // Close all with different codes - for (&connections) |*ws| { - manager.onClose(ws); - } - } -} diff --git a/src/logging_test.zig b/src/logging_test.zig new file mode 100644 index 00000000..2a8ee8bf --- /dev/null +++ b/src/logging_test.zig @@ -0,0 +1,355 @@ +const std = @import("std"); + +const helpers = @import("app_test_helpers.zig"); +const connection_manager = @import("connection/manager.zig"); +const msgpack_helpers = @import("msgpack_test_helpers.zig"); +const sth = @import("storage_engine_test_helpers.zig"); +const WebSocket = @import("uwebsockets_wrapper.zig").WebSocket; + +const testing = std.testing; + +const createMockWebSocket = helpers.createMockWebSocket; +const AppTestContext = helpers.AppTestContext; +const ConnectionManager = connection_manager.ConnectionManager; + +test "logging: connection events" { + // Connection event logging properties + // + // This property test verifies that for any client connection or disconnection, + // a log entry is written with the connection ID. + + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + 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; + try app.init(allocator, "logging-conn", &.{ + .{ .name = "_dummy", .fields = &.{"val"} }, + }); + defer app.deinit(); + + const manager = &app.connection_manager; + const memory_strategy = &app.memory_strategy; + + // Test 1: Connection open logs connection ID + // Note: We can't easily intercept std.log in tests, but we can verify + // the behavior by checking that onOpen completes successfully + // and the connection is registered + { + // Create a mock WebSocket (we'll use a stub) + // Create a mock WebSocket + var ws = createMockWebSocket(memory_strategy.generalAllocator()); + + // Handle open - this should log "WebSocket connection opened: id={}" + try manager.onOpen(&ws); + + // Verify connection was registered + const conn_id = ws.getConnId(); + const conn_state = try manager.acquireConnection(conn_id); + defer if (conn_state.release()) memory_strategy.releaseConnection(conn_state); + try testing.expectEqual(conn_id, conn_state.id); + + // Clean up + manager.onClose(&ws); + } + + // Test 2: Connection close logs connection ID + { + var ws = createMockWebSocket(memory_strategy.generalAllocator()); + + // Open connection first + try manager.onOpen(&ws); + const conn_id = ws.getConnId(); + + // Close connection - this should log "WebSocket connection closed: id={}, code={}, message={s}" + manager.onClose(&ws); + + // Verify connection was removed + const result = manager.acquireConnection(conn_id); + try testing.expectError(error.ConnectionNotFound, result); + } + + // Test 3: Multiple connections log unique IDs + { + const num_connections = 10; + var connections: [num_connections]WebSocket = undefined; + + // Open all connections + for (&connections) |*ws| { + ws.* = createMockWebSocket(memory_strategy.generalAllocator()); + try manager.onOpen(ws); + } + + // Verify all have unique IDs + var seen_ids = std.AutoHashMap(u64, void).init(allocator); + defer seen_ids.deinit(); + + for (&connections) |*ws| { + const conn_id = ws.getConnId(); + try testing.expect(!seen_ids.contains(conn_id)); + try seen_ids.put(conn_id, {}); + } + + // Close all connections + for (&connections) |*ws| { + manager.onClose(ws); + } + } + + // Test 5: Concurrent connections all log + { + const ThreadContext = struct { + manager: *ConnectionManager, + allocator: std.mem.Allocator, + iterations: usize, + }; + + const worker = struct { + fn run(ctx: *ThreadContext) void { + var i: usize = 0; + while (i < ctx.iterations) : (i += 1) { + var ws = createMockWebSocket(ctx.allocator); + + // Open and close connection + ctx.manager.onOpen(&ws) catch unreachable; // zwanzig-disable-line: swallowed-error + ctx.manager.onClose(&ws); + } + } + }.run; + + var contexts: [4]ThreadContext = undefined; + var threads: [4]std.Thread = undefined; + + for (&contexts, 0..) |*ctx, idx| { + ctx.* = .{ + .manager = manager, + .allocator = memory_strategy.generalAllocator(), + .iterations = 25, + }; + threads[idx] = try std.Thread.spawn(.{}, worker, .{ctx}); + } + + for (threads) |thread| { + thread.join(); + } + } +} + +test "logging: error details" { + // Message-parsing error logging properties + // + // 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 { + 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 + var app: AppTestContext = undefined; + try app.init(allocator, "logging-messages", &.{ + .{ .name = "_dummy", .fields = &.{"val"} }, + .{ .name = "data_table", .fields = &.{"val"} }, + }); + defer app.deinit(); + + const manager = &app.connection_manager; + const storage_engine = &app.storage_engine; + const memory_strategy = &app.memory_strategy; + + // Test 1: Message parsing errors are logged + // We can't easily intercept logs, but we can verify the error path is taken + { + var ws = createMockWebSocket(memory_strategy.generalAllocator()); + ws.user_data = &ws; + + // Open connection + try manager.onOpen(&ws); + + // Send invalid message (not MessagePack) + const invalid_msg = "not valid messagepack"; + // This should log: "Failed to parse message from connection {}: {}" + // The error is caught and logged, but doesn't propagate + manager.onMessage(&ws, invalid_msg, .binary); + + // Clean up + manager.onClose(&ws); + } + + // Test 2: Missing required fields logs error + { + var ws = createMockWebSocket(memory_strategy.generalAllocator()); + ws.user_data = &ws; + + try manager.onOpen(&ws); + + // Create message with missing fields (id, namespace, path, value) + var buf = std.ArrayListUnmanaged(u8).empty; + defer buf.deinit(allocator); + const writer = buf.writer(allocator); + try buf.append(allocator, 0x81); // fixmap(1) + try msgpack_helpers.writeMsgPackStr(writer, "type"); + try msgpack_helpers.writeMsgPackStr(writer, "StoreSet"); + + const incomplete_msg = buf.items; + + // This should log: "Failed to extract message info from connection {}: {}" + manager.onMessage(&ws, incomplete_msg, .binary); + + manager.onClose(&ws); + } + + // 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 + const record = try sth.readDoc(testing.allocator, storage_engine, tbl_md.index, 1, 1); + defer if (record) |r| r.deinit(testing.allocator); + try testing.expect(record == null); + } + + // Test 4: Multiple error types are logged + { + var ws = createMockWebSocket(memory_strategy.generalAllocator()); + ws.user_data = &ws; + + try manager.onOpen(&ws); + + // Test various error conditions + const test_cases = [_][]const u8{ + "invalid", + "{}", + "{\"type\":\"Unknown\"}", + }; + + for (test_cases) |test_msg| { + manager.onMessage(&ws, test_msg, .binary); + } + + // Also test an empty map + const empty_map = &[_]u8{0x80}; + manager.onMessage(&ws, empty_map, .binary); + + manager.onClose(&ws); + } +} + +test "logging: level filtering" { + // This property test verifies that for any log message, + // it is only written if its level meets or exceeds the configured log level. + // + // Note: Zig's std.log respects the log level at compile time and runtime. + // We verify that the logging infrastructure is in place and used correctly. + + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + 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 + { + var app: AppTestContext = undefined; + try app.init(allocator, "logging-level", &.{ + .{ .name = "test", .fields = &.{"val"} }, + }); + defer app.deinit(); + + // Trigger different log levels + var ws = createMockWebSocket(app.memory_strategy.generalAllocator()); + + // Info level: connection open + try app.connection_manager.onOpen(&ws); + + // Warn level: invalid message + app.connection_manager.onMessage(&ws, "invalid", .binary); + + // Error level: error handling + app.connection_manager.onClose(&ws); + } +} + +test "logging: message formatting" { + // This property test verifies that for any log message, + // it is formatted according to the configured format (JSON or text). + // + // Note: Zig's std.log uses a consistent format. The actual format + // can be customized via the log handler, but the default format + // is text-based with level, scope, and message. + + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + 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 + { + var app: AppTestContext = undefined; + try app.init(allocator, "logging-format", &.{ + .{ .name = "test", .fields = &.{"val"} }, + }); + defer app.deinit(); + + // Trigger various log messages + var ws = createMockWebSocket(app.memory_strategy.generalAllocator()); + + // Connection logging includes connection ID + try app.connection_manager.onOpen(&ws); + const conn_id = ws.getConnId(); + try testing.expect(conn_id > 0); + + // Error logging includes error details + app.connection_manager.onMessage(&ws, "invalid", .binary); + + // Close logging includes connection ID and close code + app.connection_manager.onClose(&ws); + } + + // Test 3: Verify log messages are properly formatted with parameters + { + 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(app.memory_strategy.generalAllocator()); + try app.connection_manager.onOpen(ws); + } + + // Close all with different codes + for (&connections) |*ws| { + app.connection_manager.onClose(ws); + } + } +} 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..f323f6f8 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,265 @@ 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.expect(value == .str); + 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.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()); +} + +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); + } + + { + const message = try store_helpers.createStoreQueryMessageWithEmptyFilter(allocator, 12, 1, table.index); + defer allocator.free(message); + + // StoreQuery is not yet implemented: no response is produced. + const response = try routeBytesOptional(&app, sc.conn, allocator, message); + try testing.expect(response == null); + } + + { + 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); + } + + { + const message = try store_helpers.createStoreQueryMessageWithEmptyFilter(allocator, 202, 1, table.index); + defer allocator.free(message); + + // StoreQuery is not yet implemented: no response is produced. + const response = try routeBytesOptional(&app, sc.conn, allocator, message); + try testing.expect(response == null); + } + + { + 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_property_test.zig deleted file mode 100644 index 19a2751b..00000000 --- a/src/migration_detector_property_test.zig +++ /dev/null @@ -1,272 +0,0 @@ -const std = @import("std"); - -const sqlite = @import("sqlite"); - -const migration_detector = @import("migration_detector.zig"); -const schema_helpers = @import("schema/test_helpers.zig"); -const schema_types = @import("schema/types.zig"); -const ddl_generator = @import("sql/ddl.zig"); - -const MigrationDetector = migration_detector.MigrationDetector; -const ChangeKind = migration_detector.ChangeKind; - -fn openMemDb() !sqlite.Db { - return sqlite.Db.init(.{ - .mode = .Memory, - .open_flags = .{ .write = true, .create = true }, - }); -} - -fn execDDL(db: *sqlite.Db, allocator: std.mem.Allocator, ddl: []const u8) !void { - const ddl_z = try allocator.dupeZ(u8, ddl); - defer allocator.free(ddl_z); - try db.execMulti(ddl_z, .{}); -} - -fn execTableDDL(db: *sqlite.Db, allocator: std.mem.Allocator, gen: *ddl_generator.DDLGenerator, table: schema_types.Table) !void { - const ddl = try gen.generateDDL(table); - defer allocator.free(ddl); - try execDDL(db, allocator, ddl); -} - -fn execSchemaDDL(db: *sqlite.Db, allocator: std.mem.Allocator, gen: *ddl_generator.DDLGenerator, schema_value: *const schema_types.Schema) !void { - for (schema_value.tables) |table| { - try execTableDDL(db, allocator, gen, table); - } -} - -// Feature: schema-aware-storage, Property 9: Migration plan accurately describes schema diff -// For any pair of schemas (old, new), the MigrationPlan produced by -// Migration_Detector.detectChanges SHALL contain exactly one Change entry for each -// table or column that differs between old and new, with the correct ChangeKind. -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); - }, - } - } -} - -// Feature: schema-aware-storage, Property 19: Matching version produces empty migration plan -// For any database whose schema matches the target Schema exactly, -// Migration_Detector.detectChanges SHALL return a MigrationPlan with zero changes. -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); - } - allocator.free(t.fields); - } - 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_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); - } - - tables[ti] = try schema_helpers.makeTableAlloc(allocator, tname, fields); - } - - 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); - - 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); - } -} diff --git a/src/migration_detector_test.zig b/src/migration_detector_test.zig new file mode 100644 index 00000000..5e1518ac --- /dev/null +++ b/src/migration_detector_test.zig @@ -0,0 +1,277 @@ +const std = @import("std"); + +const sqlite = @import("sqlite"); + +const migration_detector = @import("migration_detector.zig"); +const schema_helpers = @import("schema/test_helpers.zig"); +const schema_types = @import("schema/types.zig"); +const ddl_generator = @import("sql/ddl.zig"); + +const MigrationDetector = migration_detector.MigrationDetector; +const ChangeKind = migration_detector.ChangeKind; + +fn openMemDb() !sqlite.Db { + return sqlite.Db.init(.{ + .mode = .Memory, + .open_flags = .{ .write = true, .create = true }, + }); +} + +fn execDDL(db: *sqlite.Db, allocator: std.mem.Allocator, ddl: []const u8) !void { + const ddl_z = try allocator.dupeZ(u8, ddl); + defer allocator.free(ddl_z); + try db.execMulti(ddl_z, .{}); +} + +fn execTableDDL(db: *sqlite.Db, allocator: std.mem.Allocator, gen: *ddl_generator.DDLGenerator, table: schema_types.Table) !void { + const ddl = try gen.generateDDL(table); + defer allocator.free(ddl); + try execDDL(db, allocator, ddl); +} + +fn execSchemaDDL(db: *sqlite.Db, allocator: std.mem.Allocator, gen: *ddl_generator.DDLGenerator, schema_value: *const schema_types.Schema) !void { + for (schema_value.tables) |table| { + try execTableDDL(db, allocator, gen, table); + } +} + +// Feature: schema-aware-storage, Property 9: Migration plan accurately describes schema diff +// For any pair of schemas (old, new), the MigrationPlan produced by +// Migration_Detector.detectChanges SHALL contain exactly one Change entry for each +// table or column that differs between old and new, with the correct ChangeKind. +test "migration_detector: migration plan accurately describes schema diff" { + const allocator = std.testing.allocator; + + const table_names = [_][]const u8{ "posts", "items", "orders", "tags", "comments" }; + + // 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); + }, + } + } + } +} + +// Feature: schema-aware-storage, Property 19: Matching version produces empty migration plan +// For any database whose schema matches the target Schema exactly, +// Migration_Detector.detectChanges SHALL return a MigrationPlan with zero changes. +test "migration_detector: matching schema produces empty migration plan" { + const allocator = std.testing.allocator; + + 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 }; + + // 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(tables); + } + + 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[(fi + offset) % field_types.len]; + fields[fi] = try schema_helpers.makeFieldAlloc(allocator, mf_name, mf_type); + filled += 1; + } + + // 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 detector = MigrationDetector.init(allocator, &db, &target_schema); + const plan = try detector.detectChanges(&target_schema); + defer detector.deinit(plan); + + try std.testing.expectEqual(@as(usize, 0), plan.changes.len); + try std.testing.expect(!plan.is_destructive); + } + } +} 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..ab463017 100644 --- a/src/migration_executor_test.zig +++ b/src/migration_executor_test.zig @@ -211,3 +211,351 @@ 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); +} + +/// 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 +// SHALL still exist with all its original column values intact. +test "migration_executor: additive migration preserves existing data" { + const allocator = std.testing.allocator; + + const table_names = [_][]const u8{ "posts", "items", "orders", "tags", "comments" }; + + for (table_names) |tname| { + var db = try openMemDb(); + defer db.deinit(); + + var gen = ddl_generator.DDLGenerator.init(allocator); + + // 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); + + var stmt = try db.prepareDynamic(check_sql); + 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; + + const table_names = [_][]const u8{ "alpha", "beta", "gamma", "delta", "epsilon" }; + const destructive_kinds = [_]migration_detector.ChangeKind{ .change_type, .remove_column }; + + 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); + + var stmt = try db.prepareDynamic(check_sql); + 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 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.?); + } + } +} + +// 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; + + 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" }; + + 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); + } + } +} + +// 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; + + const table_names = [_][]const u8{ "docs", "notes", "files", "blobs", "chunks" }; + + 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); + + // 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}); + 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 + 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_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..8341f4e3 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,322 @@ 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 +// +// 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 { + 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 { + 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: 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 + try expectDecodeError(allocator, &depth_bomb, error.MaxDepthExceeded); + } + + // String bomb: str32 claiming 2 MB (max_string_length = 1 MB). + { + const str_len: u32 = 2 * 1024 * 1024; + const string_bomb = try allocator.alloc(u8, 5 + str_len); + defer allocator.free(string_bomb); + string_bomb[0] = 0xdb; // str32 + writeBe32(string_bomb, 1, str_len); + @memset(string_bomb[5..], 0); + try expectDecodeError(allocator, string_bomb, error.StringTooLong); + } +} + +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) --- + { + 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 array_exact = try allocator.alloc(u8, 5 + count); + defer allocator.free(array_exact); + + array_exact[0] = 0xdd; // array32 + writeBe32(array_exact, 1, count); + @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); + } + + // --- 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 string_exact = try allocator.alloc(u8, 5 + str_len); + defer allocator.free(string_exact); + + 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 +// +// 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 + try expectDecodeError(allocator, &depth32, error.MaxDepthExceeded); + } + + // --- Array of exactly 100,001 elements (one over max_array_length=100,000) --- + { + const count: u32 = 100001; + const array_over = try allocator.alloc(u8, 5 + count); + defer allocator.free(array_over); + + array_over[0] = 0xdd; // array32 + writeBe32(array_over, 1, count); + @memset(array_over[5..], 0xc0); // nil + try expectDecodeError(allocator, array_over, error.ArrayTooLarge); + } + + // --- 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 string_over = try allocator.alloc(u8, 5 + str_len); + defer allocator.free(string_over); + + string_over[0] = 0xdb; // str32 + writeBe32(string_over, 1, str_len); + @memset(string_over[5..], 0); + try expectDecodeError(allocator, string_over, error.StringTooLong); + } + + // --- 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); + } +} + +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()); + 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 => return error.UnsupportedRoundTripPayload, + } +} diff --git a/src/server_init_property_test.zig b/src/server_init_test.zig similarity index 64% rename from src/server_init_property_test.zig rename to src/server_init_test.zig index e3a23523..4391a970 100644 --- a/src/server_init_property_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/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); } diff --git a/src/storage_engine_error_property_test.zig b/src/storage_engine_error_test.zig similarity index 81% rename from src/storage_engine_error_property_test.zig rename to src/storage_engine_error_test.zig index e0c39a33..4f9685f6 100644 --- a/src/storage_engine_error_property_test.zig +++ b/src/storage_engine_error_test.zig @@ -38,11 +38,11 @@ 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; - 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"); @@ -54,9 +54,9 @@ test "storage: error handling read-only filesystem" { 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" { @@ -101,20 +101,34 @@ 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); + try testing.expect(record != null); 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 }); + 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); } -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; @@ -123,12 +137,12 @@ test "storage: error handling empty paths" { 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_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 84% rename from src/storage_engine_stability_property_test.zig rename to src/storage_engine_stability_test.zig index b18b81ae..5bc3e9e2 100644 --- a/src/storage_engine_stability_property_test.zig +++ b/src/storage_engine_stability_test.zig @@ -6,17 +6,14 @@ 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: 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 82023b24..d6e1e20f 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, @@ -1099,3 +1100,484 @@ 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); } + +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); + + const invalid_dir = ""; + 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); + + const file = try std.fs.cwd().createFile(test_file, .{}); + file.close(); + + 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); +} + +// 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, "storage-thread-safe", 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, + result: *?anyerror, + }; + const WriteThread = struct { + 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; + 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, 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; + const record = try sth.readDoc(testing.allocator, eng, table_index, key, 1); + defer if (record) |r| r.deinit(testing.allocator); + } + } + }; + + 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; + 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| { + thread.join(); + } + 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" { + 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-conn-pool", 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(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, "storage-persistence", 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); + _ = try sth.expectFieldString(record.?, test_table.metadata, "val", tc.value); + } +} + +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: 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); + 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); + _ = 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: 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); + 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.deinitNoCleanup(); + + 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.deinitNoCleanup(); + + 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); + _ = try sth.expectFieldString(record1.?, test_table.metadata, "val1", "value1"); + + // 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); + _ = try sth.expectFieldInt(record2.?, test_table.metadata, "val2", 42); + } +} + +// 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(); + + 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); + 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 = items_index, .id = id, .namespace_id = ns } }); + }, + 2 => { + // Query + 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..d6bce444 100644 --- a/src/test_all.zig +++ b/src/test_all.zig @@ -62,29 +62,22 @@ 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"); _ = @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..7a2cf229 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,338 @@ 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: callback contract - handlers invoked per registration" { + 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{}; + + // 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, + }; + + // Simulate WebSocket events by calling the handlers directly + // (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) { + 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{}; + + 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{}; + + 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: callback invocation counts reflect events" { + const allocator = testing.allocator; + + var ctx = CallbackContext{}; + + 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); + defer destroyMockWebSocket(allocator, &mock_ws); + + // 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); +} + +// Helper types and functions + +const CallbackContext = struct { + 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 { + _ = 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"