diff --git a/bolt/src/test/java/com/arcadedb/bolt/BoltChunkedIOTest.java b/bolt/src/test/java/com/arcadedb/bolt/BoltChunkedIOTest.java new file mode 100644 index 0000000000..d3379cff1b --- /dev/null +++ b/bolt/src/test/java/com/arcadedb/bolt/BoltChunkedIOTest.java @@ -0,0 +1,298 @@ +/* + * Copyright 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.bolt; + +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for BOLT chunked I/O classes. + */ +class BoltChunkedIOTest { + + // ============ BoltChunkedOutput tests ============ + + @Test + void writeEmptyMessage() throws IOException { + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + final BoltChunkedOutput output = new BoltChunkedOutput(baos); + + output.writeMessage(new byte[0]); + + final byte[] result = baos.toByteArray(); + // Should only contain end marker (0x00 0x00) + assertThat(result).hasSize(2); + assertThat(result[0]).isEqualTo((byte) 0x00); + assertThat(result[1]).isEqualTo((byte) 0x00); + } + + @Test + void writeSmallMessage() throws IOException { + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + final BoltChunkedOutput output = new BoltChunkedOutput(baos); + + final byte[] message = { 0x01, 0x02, 0x03, 0x04, 0x05 }; + output.writeMessage(message); + + final byte[] result = baos.toByteArray(); + // 2 bytes for size + 5 bytes data + 2 bytes end marker + assertThat(result).hasSize(9); + // Check chunk size (big-endian) + assertThat(result[0]).isEqualTo((byte) 0x00); + assertThat(result[1]).isEqualTo((byte) 0x05); + // Check data + assertThat(result[2]).isEqualTo((byte) 0x01); + assertThat(result[3]).isEqualTo((byte) 0x02); + assertThat(result[4]).isEqualTo((byte) 0x03); + assertThat(result[5]).isEqualTo((byte) 0x04); + assertThat(result[6]).isEqualTo((byte) 0x05); + // Check end marker + assertThat(result[7]).isEqualTo((byte) 0x00); + assertThat(result[8]).isEqualTo((byte) 0x00); + } + + @Test + void writeMessageExactlyMaxChunkSize() throws IOException { + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + final BoltChunkedOutput output = new BoltChunkedOutput(baos); + + // Create message exactly 65535 bytes (max chunk size) + final byte[] message = new byte[65535]; + Arrays.fill(message, (byte) 0xAB); + output.writeMessage(message); + + final byte[] result = baos.toByteArray(); + // 2 bytes for size + 65535 bytes data + 2 bytes end marker + assertThat(result).hasSize(65539); + // Check chunk size (0xFFFF = 65535) + assertThat(result[0]).isEqualTo((byte) 0xFF); + assertThat(result[1]).isEqualTo((byte) 0xFF); + } + + @Test + void writeMessageLargerThanMaxChunkSize() throws IOException { + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + final BoltChunkedOutput output = new BoltChunkedOutput(baos); + + // Create message larger than max chunk size (65535 + 100 = 65635 bytes) + final byte[] message = new byte[65635]; + Arrays.fill(message, 0, 65535, (byte) 0xAA); + Arrays.fill(message, 65535, 65635, (byte) 0xBB); + output.writeMessage(message); + + final byte[] result = baos.toByteArray(); + // First chunk: 2 bytes size + 65535 bytes + // Second chunk: 2 bytes size + 100 bytes + // End marker: 2 bytes + assertThat(result).hasSize(65641); + + // First chunk size (0xFFFF) + assertThat(result[0]).isEqualTo((byte) 0xFF); + assertThat(result[1]).isEqualTo((byte) 0xFF); + + // Second chunk size (0x0064 = 100) + assertThat(result[65537]).isEqualTo((byte) 0x00); + assertThat(result[65538]).isEqualTo((byte) 0x64); + + // End marker at the end + assertThat(result[65639]).isEqualTo((byte) 0x00); + assertThat(result[65640]).isEqualTo((byte) 0x00); + } + + @Test + void writeRawBytes() throws IOException { + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + final BoltChunkedOutput output = new BoltChunkedOutput(baos); + + final byte[] raw = { 0x60, 0x60, (byte) 0xB0, 0x17 }; // BOLT magic + output.writeRaw(raw); + + assertThat(baos.toByteArray()).isEqualTo(raw); + } + + @Test + void writeRawInt() throws IOException { + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + final BoltChunkedOutput output = new BoltChunkedOutput(baos); + + output.writeRawInt(0x00000104); // BOLT version 4.4 + + final byte[] result = baos.toByteArray(); + assertThat(result).hasSize(4); + assertThat(result[0]).isEqualTo((byte) 0x00); + assertThat(result[1]).isEqualTo((byte) 0x00); + assertThat(result[2]).isEqualTo((byte) 0x01); + assertThat(result[3]).isEqualTo((byte) 0x04); + } + + // ============ BoltChunkedInput tests ============ + + @Test + void readEmptyMessage() throws IOException { + // Just end marker + final byte[] input = { 0x00, 0x00 }; + final BoltChunkedInput chunkedInput = new BoltChunkedInput(new ByteArrayInputStream(input)); + + final byte[] message = chunkedInput.readMessage(); + assertThat(message).isEmpty(); + } + + @Test + void readSmallMessage() throws IOException { + // Size (5) + data + end marker + final byte[] input = { + 0x00, 0x05, // chunk size = 5 + 0x01, 0x02, 0x03, 0x04, 0x05, // data + 0x00, 0x00 // end marker + }; + final BoltChunkedInput chunkedInput = new BoltChunkedInput(new ByteArrayInputStream(input)); + + final byte[] message = chunkedInput.readMessage(); + assertThat(message).containsExactly(0x01, 0x02, 0x03, 0x04, 0x05); + } + + @Test + void readMultiChunkMessage() throws IOException { + // Two chunks: 3 bytes + 2 bytes + final byte[] input = { + 0x00, 0x03, // first chunk size = 3 + 0x0A, 0x0B, 0x0C, // first chunk data + 0x00, 0x02, // second chunk size = 2 + 0x0D, 0x0E, // second chunk data + 0x00, 0x00 // end marker + }; + final BoltChunkedInput chunkedInput = new BoltChunkedInput(new ByteArrayInputStream(input)); + + final byte[] message = chunkedInput.readMessage(); + assertThat(message).containsExactly(0x0A, 0x0B, 0x0C, 0x0D, 0x0E); + } + + @Test + void readRawBytes() throws IOException { + final byte[] input = { 0x60, 0x60, (byte) 0xB0, 0x17, 0x00, 0x00 }; + final BoltChunkedInput chunkedInput = new BoltChunkedInput(new ByteArrayInputStream(input)); + + final byte[] raw = chunkedInput.readRaw(4); + assertThat(raw).containsExactly(0x60, 0x60, 0xB0, 0x17); + } + + @Test + void readRawInt() throws IOException { + final byte[] input = { 0x00, 0x00, 0x01, 0x04 }; + final BoltChunkedInput chunkedInput = new BoltChunkedInput(new ByteArrayInputStream(input)); + + final int value = chunkedInput.readRawInt(); + assertThat(value).isEqualTo(0x00000104); + } + + @Test + void readRawShort() throws IOException { + final byte[] input = { 0x00, 0x05 }; + final BoltChunkedInput chunkedInput = new BoltChunkedInput(new ByteArrayInputStream(input)); + + final int value = chunkedInput.readRawShort(); + assertThat(value).isEqualTo(5); + } + + @Test + void readLargeChunkSize() throws IOException { + // Chunk size 0xFFFF (65535) + final byte[] input = new byte[65535 + 4]; // size header + data + end marker + input[0] = (byte) 0xFF; + input[1] = (byte) 0xFF; + Arrays.fill(input, 2, 65537, (byte) 0xCC); + input[65537] = 0x00; + input[65538] = 0x00; + + final BoltChunkedInput chunkedInput = new BoltChunkedInput(new ByteArrayInputStream(input)); + final byte[] message = chunkedInput.readMessage(); + + assertThat(message).hasSize(65535); + assertThat(message[0]).isEqualTo((byte) 0xCC); + assertThat(message[message.length - 1]).isEqualTo((byte) 0xCC); + } + + @Test + void available() throws IOException { + final byte[] input = { 0x01, 0x02, 0x03 }; + final BoltChunkedInput chunkedInput = new BoltChunkedInput(new ByteArrayInputStream(input)); + + assertThat(chunkedInput.available()).isEqualTo(3); + } + + // ============ Round-trip tests ============ + + @Test + void roundTripSmallMessage() throws IOException { + final byte[] originalMessage = { 0x01, 0x02, 0x03, 0x04, 0x05 }; + + // Write + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + final BoltChunkedOutput output = new BoltChunkedOutput(baos); + output.writeMessage(originalMessage); + + // Read + final BoltChunkedInput input = new BoltChunkedInput(new ByteArrayInputStream(baos.toByteArray())); + final byte[] readMessage = input.readMessage(); + + assertThat(readMessage).isEqualTo(originalMessage); + } + + @Test + void roundTripLargeMessage() throws IOException { + // Message larger than max chunk size + final byte[] originalMessage = new byte[100000]; + for (int i = 0; i < originalMessage.length; i++) { + originalMessage[i] = (byte) (i % 256); + } + + // Write + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + final BoltChunkedOutput output = new BoltChunkedOutput(baos); + output.writeMessage(originalMessage); + + // Read + final BoltChunkedInput input = new BoltChunkedInput(new ByteArrayInputStream(baos.toByteArray())); + final byte[] readMessage = input.readMessage(); + + assertThat(readMessage).isEqualTo(originalMessage); + } + + @Test + void roundTripEmptyMessage() throws IOException { + final byte[] originalMessage = new byte[0]; + + // Write + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + final BoltChunkedOutput output = new BoltChunkedOutput(baos); + output.writeMessage(originalMessage); + + // Read + final BoltChunkedInput input = new BoltChunkedInput(new ByteArrayInputStream(baos.toByteArray())); + final byte[] readMessage = input.readMessage(); + + assertThat(readMessage).isEqualTo(originalMessage); + } +} diff --git a/docs/plans/2026-02-05-server-module-test-coverage.md b/docs/plans/2026-02-05-server-module-test-coverage.md new file mode 100644 index 0000000000..6c2e736e3e --- /dev/null +++ b/docs/plans/2026-02-05-server-module-test-coverage.md @@ -0,0 +1,920 @@ +# Server Module Test Coverage Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add unit tests for the server module to improve test coverage, focusing on backup components and HTTP handler utilities. + +**Architecture:** Unit tests for isolated components (DatabaseBackupConfig, ExecutionResponse) and integration-style tests for BackupScheduler. Tests use AssertJ assertions and follow existing project patterns. + +**Tech Stack:** JUnit 5, AssertJ, Java 21 + +--- + +## Task 1: DatabaseBackupConfig Unit Tests + +**Files:** +- Create: `server/src/test/java/com/arcadedb/server/backup/DatabaseBackupConfigTest.java` + +**Step 1: Write test file with basic configuration tests** + +```java +/* + * Copyright 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.server.backup; + +import com.arcadedb.serializer.json.JSONObject; +import org.junit.jupiter.api.Test; + +import java.time.LocalTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for DatabaseBackupConfig and its nested configuration classes. + */ +class DatabaseBackupConfigTest { + + // ============ DatabaseBackupConfig tests ============ + + @Test + void createWithDatabaseName() { + final DatabaseBackupConfig config = new DatabaseBackupConfig("testdb"); + + assertThat(config.getDatabaseName()).isEqualTo("testdb"); + assertThat(config.isEnabled()).isTrue(); // Default + assertThat(config.getRunOnServer()).isEqualTo("$leader"); // Default + } + + @Test + void fromJsonBasic() { + final JSONObject json = new JSONObject() + .put("enabled", false) + .put("runOnServer", "server1"); + + final DatabaseBackupConfig config = DatabaseBackupConfig.fromJSON("mydb", json); + + assertThat(config.getDatabaseName()).isEqualTo("mydb"); + assertThat(config.isEnabled()).isFalse(); + assertThat(config.getRunOnServer()).isEqualTo("server1"); + } + + @Test + void fromJsonWithSchedule() { + final JSONObject scheduleJson = new JSONObject() + .put("type", "FREQUENCY") + .put("frequencyMinutes", 30); + + final JSONObject json = new JSONObject() + .put("schedule", scheduleJson); + + final DatabaseBackupConfig config = DatabaseBackupConfig.fromJSON("mydb", json); + + assertThat(config.getSchedule()).isNotNull(); + assertThat(config.getSchedule().getType()).isEqualTo(DatabaseBackupConfig.ScheduleConfig.Type.FREQUENCY); + assertThat(config.getSchedule().getFrequencyMinutes()).isEqualTo(30); + } + + @Test + void fromJsonWithRetention() { + final JSONObject retentionJson = new JSONObject() + .put("maxFiles", 20); + + final JSONObject json = new JSONObject() + .put("retention", retentionJson); + + final DatabaseBackupConfig config = DatabaseBackupConfig.fromJSON("mydb", json); + + assertThat(config.getRetention()).isNotNull(); + assertThat(config.getRetention().getMaxFiles()).isEqualTo(20); + } + + @Test + void toJsonRoundTrip() { + final DatabaseBackupConfig config = new DatabaseBackupConfig("testdb"); + config.setEnabled(false); + config.setRunOnServer("server2"); + + final DatabaseBackupConfig.ScheduleConfig schedule = new DatabaseBackupConfig.ScheduleConfig(); + schedule.setType(DatabaseBackupConfig.ScheduleConfig.Type.FREQUENCY); + schedule.setFrequencyMinutes(45); + config.setSchedule(schedule); + + final JSONObject json = config.toJSON(); + + assertThat(json.getBoolean("enabled")).isFalse(); + assertThat(json.getString("runOnServer")).isEqualTo("server2"); + assertThat(json.has("schedule")).isTrue(); + } + + @Test + void mergeWithDefaultsSchedule() { + final DatabaseBackupConfig defaults = new DatabaseBackupConfig("default"); + final DatabaseBackupConfig.ScheduleConfig defaultSchedule = new DatabaseBackupConfig.ScheduleConfig(); + defaultSchedule.setFrequencyMinutes(60); + defaults.setSchedule(defaultSchedule); + + final DatabaseBackupConfig config = new DatabaseBackupConfig("testdb"); + config.mergeWithDefaults(defaults); + + assertThat(config.getSchedule()).isNotNull(); + assertThat(config.getSchedule().getFrequencyMinutes()).isEqualTo(60); + } + + @Test + void mergeWithDefaultsRetention() { + final DatabaseBackupConfig defaults = new DatabaseBackupConfig("default"); + final DatabaseBackupConfig.RetentionConfig defaultRetention = new DatabaseBackupConfig.RetentionConfig(); + defaultRetention.setMaxFiles(15); + defaults.setRetention(defaultRetention); + + final DatabaseBackupConfig config = new DatabaseBackupConfig("testdb"); + config.mergeWithDefaults(defaults); + + assertThat(config.getRetention()).isNotNull(); + assertThat(config.getRetention().getMaxFiles()).isEqualTo(15); + } + + @Test + void mergeWithNullDefaults() { + final DatabaseBackupConfig config = new DatabaseBackupConfig("testdb"); + config.mergeWithDefaults(null); + + // Should not throw, schedule and retention remain null + assertThat(config.getSchedule()).isNull(); + assertThat(config.getRetention()).isNull(); + } + + // ============ ScheduleConfig tests ============ + + @Test + void scheduleConfigFromJsonFrequency() { + final JSONObject json = new JSONObject() + .put("type", "frequency") + .put("frequencyMinutes", 120); + + final DatabaseBackupConfig.ScheduleConfig config = DatabaseBackupConfig.ScheduleConfig.fromJSON(json); + + assertThat(config.getType()).isEqualTo(DatabaseBackupConfig.ScheduleConfig.Type.FREQUENCY); + assertThat(config.getFrequencyMinutes()).isEqualTo(120); + } + + @Test + void scheduleConfigFromJsonCron() { + final JSONObject json = new JSONObject() + .put("type", "CRON") + .put("expression", "0 0 2 * * *"); + + final DatabaseBackupConfig.ScheduleConfig config = DatabaseBackupConfig.ScheduleConfig.fromJSON(json); + + assertThat(config.getType()).isEqualTo(DatabaseBackupConfig.ScheduleConfig.Type.CRON); + assertThat(config.getCronExpression()).isEqualTo("0 0 2 * * *"); + } + + @Test + void scheduleConfigWithTimeWindow() { + final JSONObject windowJson = new JSONObject() + .put("start", "02:00") + .put("end", "04:00"); + + final JSONObject json = new JSONObject() + .put("timeWindow", windowJson); + + final DatabaseBackupConfig.ScheduleConfig config = DatabaseBackupConfig.ScheduleConfig.fromJSON(json); + + assertThat(config.hasTimeWindow()).isTrue(); + assertThat(config.getWindowStart()).isEqualTo(LocalTime.of(2, 0)); + assertThat(config.getWindowEnd()).isEqualTo(LocalTime.of(4, 0)); + } + + @Test + void scheduleConfigValidateFrequencyTooLow() { + final DatabaseBackupConfig.ScheduleConfig config = new DatabaseBackupConfig.ScheduleConfig(); + config.setType(DatabaseBackupConfig.ScheduleConfig.Type.FREQUENCY); + config.setFrequencyMinutes(0); + + assertThatThrownBy(config::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("at least 1 minute"); + } + + @Test + void scheduleConfigValidateFrequencyTooHigh() { + final DatabaseBackupConfig.ScheduleConfig config = new DatabaseBackupConfig.ScheduleConfig(); + config.setType(DatabaseBackupConfig.ScheduleConfig.Type.FREQUENCY); + config.setFrequencyMinutes(600000); + + assertThatThrownBy(config::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot exceed 1 year"); + } + + @Test + void scheduleConfigValidateCronMissing() { + final DatabaseBackupConfig.ScheduleConfig config = new DatabaseBackupConfig.ScheduleConfig(); + config.setType(DatabaseBackupConfig.ScheduleConfig.Type.CRON); + config.setCronExpression(null); + + assertThatThrownBy(config::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("CRON expression is required"); + } + + @Test + void scheduleConfigValidateCronInvalid() { + final DatabaseBackupConfig.ScheduleConfig config = new DatabaseBackupConfig.ScheduleConfig(); + config.setType(DatabaseBackupConfig.ScheduleConfig.Type.CRON); + config.setCronExpression("invalid"); + + assertThatThrownBy(config::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid CRON expression"); + } + + @Test + void scheduleConfigToJson() { + final DatabaseBackupConfig.ScheduleConfig config = new DatabaseBackupConfig.ScheduleConfig(); + config.setType(DatabaseBackupConfig.ScheduleConfig.Type.FREQUENCY); + config.setFrequencyMinutes(30); + config.setWindowStart(LocalTime.of(1, 0)); + config.setWindowEnd(LocalTime.of(5, 0)); + + final JSONObject json = config.toJSON(); + + assertThat(json.getString("type")).isEqualTo("frequency"); + assertThat(json.getInt("frequencyMinutes")).isEqualTo(30); + assertThat(json.has("timeWindow")).isTrue(); + } + + @Test + void scheduleConfigMergeTimeWindow() { + final DatabaseBackupConfig.ScheduleConfig defaults = new DatabaseBackupConfig.ScheduleConfig(); + defaults.setWindowStart(LocalTime.of(2, 0)); + defaults.setWindowEnd(LocalTime.of(4, 0)); + + final DatabaseBackupConfig.ScheduleConfig config = new DatabaseBackupConfig.ScheduleConfig(); + config.mergeWithDefaults(defaults); + + assertThat(config.getWindowStart()).isEqualTo(LocalTime.of(2, 0)); + assertThat(config.getWindowEnd()).isEqualTo(LocalTime.of(4, 0)); + } + + // ============ RetentionConfig tests ============ + + @Test + void retentionConfigFromJson() { + final JSONObject json = new JSONObject() + .put("maxFiles", 25); + + final DatabaseBackupConfig.RetentionConfig config = DatabaseBackupConfig.RetentionConfig.fromJSON(json); + + assertThat(config.getMaxFiles()).isEqualTo(25); + } + + @Test + void retentionConfigWithTiered() { + final JSONObject tieredJson = new JSONObject() + .put("hourly", 12) + .put("daily", 5) + .put("weekly", 2) + .put("monthly", 6) + .put("yearly", 1); + + final JSONObject json = new JSONObject() + .put("tiered", tieredJson); + + final DatabaseBackupConfig.RetentionConfig config = DatabaseBackupConfig.RetentionConfig.fromJSON(json); + + assertThat(config.hasTieredRetention()).isTrue(); + assertThat(config.getTiered().getHourly()).isEqualTo(12); + assertThat(config.getTiered().getDaily()).isEqualTo(5); + assertThat(config.getTiered().getWeekly()).isEqualTo(2); + assertThat(config.getTiered().getMonthly()).isEqualTo(6); + assertThat(config.getTiered().getYearly()).isEqualTo(1); + } + + @Test + void retentionConfigValidateMaxFilesTooLow() { + final DatabaseBackupConfig.RetentionConfig config = new DatabaseBackupConfig.RetentionConfig(); + config.setMaxFiles(0); + + assertThatThrownBy(config::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("at least 1"); + } + + @Test + void retentionConfigValidateMaxFilesTooHigh() { + final DatabaseBackupConfig.RetentionConfig config = new DatabaseBackupConfig.RetentionConfig(); + config.setMaxFiles(20000); + + assertThatThrownBy(config::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot exceed 10000"); + } + + @Test + void retentionConfigToJson() { + final DatabaseBackupConfig.RetentionConfig config = new DatabaseBackupConfig.RetentionConfig(); + config.setMaxFiles(50); + + final JSONObject json = config.toJSON(); + + assertThat(json.getInt("maxFiles")).isEqualTo(50); + } + + // ============ TieredConfig tests ============ + + @Test + void tieredConfigFromJson() { + final JSONObject json = new JSONObject() + .put("hourly", 48) + .put("daily", 14) + .put("weekly", 8) + .put("monthly", 24) + .put("yearly", 5); + + final DatabaseBackupConfig.TieredConfig config = DatabaseBackupConfig.TieredConfig.fromJSON(json); + + assertThat(config.getHourly()).isEqualTo(48); + assertThat(config.getDaily()).isEqualTo(14); + assertThat(config.getWeekly()).isEqualTo(8); + assertThat(config.getMonthly()).isEqualTo(24); + assertThat(config.getYearly()).isEqualTo(5); + } + + @Test + void tieredConfigDefaults() { + final DatabaseBackupConfig.TieredConfig config = new DatabaseBackupConfig.TieredConfig(); + + assertThat(config.getHourly()).isEqualTo(24); + assertThat(config.getDaily()).isEqualTo(7); + assertThat(config.getWeekly()).isEqualTo(4); + assertThat(config.getMonthly()).isEqualTo(12); + assertThat(config.getYearly()).isEqualTo(3); + } + + @Test + void tieredConfigValidateNegative() { + final DatabaseBackupConfig.TieredConfig config = new DatabaseBackupConfig.TieredConfig(); + config.setHourly(-1); + + assertThatThrownBy(config::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot be negative"); + } + + @Test + void tieredConfigValidateTooHigh() { + final DatabaseBackupConfig.TieredConfig config = new DatabaseBackupConfig.TieredConfig(); + config.setDaily(2000); + + assertThatThrownBy(config::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot exceed 1000"); + } + + @Test + void tieredConfigToJson() { + final DatabaseBackupConfig.TieredConfig config = new DatabaseBackupConfig.TieredConfig(); + config.setHourly(10); + config.setDaily(5); + + final JSONObject json = config.toJSON(); + + assertThat(json.getInt("hourly")).isEqualTo(10); + assertThat(json.getInt("daily")).isEqualTo(5); + } + + // ============ Setters tests ============ + + @Test + void settersWork() { + final DatabaseBackupConfig config = new DatabaseBackupConfig("db"); + config.setEnabled(false); + config.setRunOnServer("myserver"); + + assertThat(config.isEnabled()).isFalse(); + assertThat(config.getRunOnServer()).isEqualTo("myserver"); + } + + @Test + void scheduleSettersWork() { + final DatabaseBackupConfig.ScheduleConfig config = new DatabaseBackupConfig.ScheduleConfig(); + config.setType(DatabaseBackupConfig.ScheduleConfig.Type.CRON); + config.setCronExpression("0 0 * * * *"); + config.setFrequencyMinutes(99); + + assertThat(config.getType()).isEqualTo(DatabaseBackupConfig.ScheduleConfig.Type.CRON); + assertThat(config.getCronExpression()).isEqualTo("0 0 * * * *"); + assertThat(config.getFrequencyMinutes()).isEqualTo(99); + } + + @Test + void retentionSettersWork() { + final DatabaseBackupConfig.RetentionConfig retention = new DatabaseBackupConfig.RetentionConfig(); + final DatabaseBackupConfig.TieredConfig tiered = new DatabaseBackupConfig.TieredConfig(); + retention.setTiered(tiered); + + assertThat(retention.getTiered()).isSameAs(tiered); + } + + @Test + void tieredSettersWork() { + final DatabaseBackupConfig.TieredConfig config = new DatabaseBackupConfig.TieredConfig(); + config.setWeekly(10); + config.setMonthly(20); + config.setYearly(30); + + assertThat(config.getWeekly()).isEqualTo(10); + assertThat(config.getMonthly()).isEqualTo(20); + assertThat(config.getYearly()).isEqualTo(30); + } +} +``` + +**Step 2: Run tests to verify they pass** + +Run: `mvn test -pl server -Dtest=DatabaseBackupConfigTest -q` +Expected: All tests PASS + +**Step 3: Commit** + +```bash +git add server/src/test/java/com/arcadedb/server/backup/DatabaseBackupConfigTest.java +git commit -m "test: add DatabaseBackupConfig unit tests + +Cover configuration parsing, validation, JSON round-trip, +and merge with defaults for schedule, retention, and tiered configs." +``` + +--- + +## Task 2: ExecutionResponse Unit Tests + +**Files:** +- Create: `server/src/test/java/com/arcadedb/server/http/handler/ExecutionResponseTest.java` + +**Step 1: Write test file** + +```java +/* + * Copyright 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.server.http.handler; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for ExecutionResponse. + */ +class ExecutionResponseTest { + + @Test + void createWithStringResponse() { + final ExecutionResponse response = new ExecutionResponse(200, "success"); + + // We can't directly access fields, but we can verify construction doesn't throw + assertThat(response).isNotNull(); + } + + @Test + void createWithBinaryResponse() { + final byte[] data = {0x01, 0x02, 0x03, 0x04}; + final ExecutionResponse response = new ExecutionResponse(201, data); + + assertThat(response).isNotNull(); + } + + @Test + void createWithDifferentStatusCodes() { + final ExecutionResponse ok = new ExecutionResponse(200, "ok"); + final ExecutionResponse created = new ExecutionResponse(201, "created"); + final ExecutionResponse badRequest = new ExecutionResponse(400, "bad request"); + final ExecutionResponse serverError = new ExecutionResponse(500, "error"); + + assertThat(ok).isNotNull(); + assertThat(created).isNotNull(); + assertThat(badRequest).isNotNull(); + assertThat(serverError).isNotNull(); + } + + @Test + void createWithEmptyStringResponse() { + final ExecutionResponse response = new ExecutionResponse(204, ""); + + assertThat(response).isNotNull(); + } + + @Test + void createWithEmptyBinaryResponse() { + final ExecutionResponse response = new ExecutionResponse(204, new byte[0]); + + assertThat(response).isNotNull(); + } + + @Test + void createWithLargeStringResponse() { + final String largeResponse = "x".repeat(100000); + final ExecutionResponse response = new ExecutionResponse(200, largeResponse); + + assertThat(response).isNotNull(); + } + + @Test + void createWithLargeBinaryResponse() { + final byte[] largeData = new byte[100000]; + final ExecutionResponse response = new ExecutionResponse(200, largeData); + + assertThat(response).isNotNull(); + } + + @Test + void createWithJsonResponse() { + final String jsonResponse = "{\"status\":\"ok\",\"count\":42}"; + final ExecutionResponse response = new ExecutionResponse(200, jsonResponse); + + assertThat(response).isNotNull(); + } + + @Test + void createWithNullStringResponse() { + // Null string response should be handled + final ExecutionResponse response = new ExecutionResponse(200, (String) null); + + assertThat(response).isNotNull(); + } +} +``` + +**Step 2: Run tests to verify they pass** + +Run: `mvn test -pl server -Dtest=ExecutionResponseTest -q` +Expected: All tests PASS + +**Step 3: Commit** + +```bash +git add server/src/test/java/com/arcadedb/server/http/handler/ExecutionResponseTest.java +git commit -m "test: add ExecutionResponse unit tests + +Cover string and binary response creation with various status codes." +``` + +--- + +## Task 3: BackupScheduler Unit Tests + +**Files:** +- Create: `server/src/test/java/com/arcadedb/server/backup/BackupSchedulerTest.java` + +**Step 1: Write test file** + +```java +/* + * Copyright 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.server.backup; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for BackupScheduler lifecycle and scheduling logic. + */ +class BackupSchedulerTest { + + private BackupScheduler scheduler; + + @BeforeEach + void setUp() { + // Create scheduler with null server (won't execute backups, just tests scheduling) + scheduler = new BackupScheduler(null, "/tmp/backups", null); + } + + @AfterEach + void tearDown() { + if (scheduler != null && scheduler.isRunning()) { + scheduler.stop(); + } + } + + @Test + void initialStateNotRunning() { + assertThat(scheduler.isRunning()).isFalse(); + assertThat(scheduler.getScheduledCount()).isEqualTo(0); + } + + @Test + void startSetsRunningTrue() { + scheduler.start(); + + assertThat(scheduler.isRunning()).isTrue(); + } + + @Test + void stopSetsRunningFalse() { + scheduler.start(); + scheduler.stop(); + + assertThat(scheduler.isRunning()).isFalse(); + } + + @Test + void scheduleBeforeStartDoesNotSchedule() { + final DatabaseBackupConfig config = new DatabaseBackupConfig("testdb"); + config.setEnabled(true); + final DatabaseBackupConfig.ScheduleConfig schedule = new DatabaseBackupConfig.ScheduleConfig(); + schedule.setType(DatabaseBackupConfig.ScheduleConfig.Type.FREQUENCY); + schedule.setFrequencyMinutes(60); + config.setSchedule(schedule); + + // Not started yet + scheduler.scheduleBackup("testdb", config); + + assertThat(scheduler.getScheduledCount()).isEqualTo(0); + } + + @Test + void scheduleDisabledConfigDoesNotSchedule() { + scheduler.start(); + + final DatabaseBackupConfig config = new DatabaseBackupConfig("testdb"); + config.setEnabled(false); + + scheduler.scheduleBackup("testdb", config); + + assertThat(scheduler.getScheduledCount()).isEqualTo(0); + } + + @Test + void scheduleWithNoScheduleConfigDoesNotSchedule() { + scheduler.start(); + + final DatabaseBackupConfig config = new DatabaseBackupConfig("testdb"); + config.setEnabled(true); + // No schedule set + + scheduler.scheduleBackup("testdb", config); + + assertThat(scheduler.getScheduledCount()).isEqualTo(0); + } + + @Test + void scheduleFrequencyBasedBackup() { + scheduler.start(); + + final DatabaseBackupConfig config = new DatabaseBackupConfig("testdb"); + config.setEnabled(true); + final DatabaseBackupConfig.ScheduleConfig schedule = new DatabaseBackupConfig.ScheduleConfig(); + schedule.setType(DatabaseBackupConfig.ScheduleConfig.Type.FREQUENCY); + schedule.setFrequencyMinutes(60); + config.setSchedule(schedule); + + scheduler.scheduleBackup("testdb", config); + + assertThat(scheduler.getScheduledCount()).isEqualTo(1); + } + + @Test + void scheduleCronBasedBackup() { + scheduler.start(); + + final DatabaseBackupConfig config = new DatabaseBackupConfig("testdb"); + config.setEnabled(true); + final DatabaseBackupConfig.ScheduleConfig schedule = new DatabaseBackupConfig.ScheduleConfig(); + schedule.setType(DatabaseBackupConfig.ScheduleConfig.Type.CRON); + schedule.setCronExpression("0 0 2 * * *"); + config.setSchedule(schedule); + + scheduler.scheduleBackup("testdb", config); + + assertThat(scheduler.getScheduledCount()).isEqualTo(1); + } + + @Test + void cancelBackupRemovesFromSchedule() { + scheduler.start(); + + final DatabaseBackupConfig config = new DatabaseBackupConfig("testdb"); + config.setEnabled(true); + final DatabaseBackupConfig.ScheduleConfig schedule = new DatabaseBackupConfig.ScheduleConfig(); + schedule.setType(DatabaseBackupConfig.ScheduleConfig.Type.FREQUENCY); + schedule.setFrequencyMinutes(60); + config.setSchedule(schedule); + + scheduler.scheduleBackup("testdb", config); + assertThat(scheduler.getScheduledCount()).isEqualTo(1); + + scheduler.cancelBackup("testdb"); + assertThat(scheduler.getScheduledCount()).isEqualTo(0); + } + + @Test + void cancelNonExistentBackupDoesNotThrow() { + scheduler.start(); + + // Should not throw + scheduler.cancelBackup("nonexistent"); + + assertThat(scheduler.getScheduledCount()).isEqualTo(0); + } + + @Test + void rescheduleReplacesExisting() { + scheduler.start(); + + final DatabaseBackupConfig config1 = new DatabaseBackupConfig("testdb"); + config1.setEnabled(true); + final DatabaseBackupConfig.ScheduleConfig schedule1 = new DatabaseBackupConfig.ScheduleConfig(); + schedule1.setType(DatabaseBackupConfig.ScheduleConfig.Type.FREQUENCY); + schedule1.setFrequencyMinutes(60); + config1.setSchedule(schedule1); + + scheduler.scheduleBackup("testdb", config1); + assertThat(scheduler.getScheduledCount()).isEqualTo(1); + + // Reschedule with different config + final DatabaseBackupConfig config2 = new DatabaseBackupConfig("testdb"); + config2.setEnabled(true); + final DatabaseBackupConfig.ScheduleConfig schedule2 = new DatabaseBackupConfig.ScheduleConfig(); + schedule2.setType(DatabaseBackupConfig.ScheduleConfig.Type.FREQUENCY); + schedule2.setFrequencyMinutes(30); + config2.setSchedule(schedule2); + + scheduler.scheduleBackup("testdb", config2); + + // Still only 1 scheduled (replaced) + assertThat(scheduler.getScheduledCount()).isEqualTo(1); + } + + @Test + void multipleBackupsCanBeScheduled() { + scheduler.start(); + + for (int i = 1; i <= 3; i++) { + final DatabaseBackupConfig config = new DatabaseBackupConfig("db" + i); + config.setEnabled(true); + final DatabaseBackupConfig.ScheduleConfig schedule = new DatabaseBackupConfig.ScheduleConfig(); + schedule.setType(DatabaseBackupConfig.ScheduleConfig.Type.FREQUENCY); + schedule.setFrequencyMinutes(60); + config.setSchedule(schedule); + scheduler.scheduleBackup("db" + i, config); + } + + assertThat(scheduler.getScheduledCount()).isEqualTo(3); + } + + @Test + void stopCancelsAllScheduledTasks() { + scheduler.start(); + + for (int i = 1; i <= 3; i++) { + final DatabaseBackupConfig config = new DatabaseBackupConfig("db" + i); + config.setEnabled(true); + final DatabaseBackupConfig.ScheduleConfig schedule = new DatabaseBackupConfig.ScheduleConfig(); + schedule.setType(DatabaseBackupConfig.ScheduleConfig.Type.FREQUENCY); + schedule.setFrequencyMinutes(60); + config.setSchedule(schedule); + scheduler.scheduleBackup("db" + i, config); + } + + assertThat(scheduler.getScheduledCount()).isEqualTo(3); + + scheduler.stop(); + + assertThat(scheduler.getScheduledCount()).isEqualTo(0); + } + + @Test + void triggerImmediateBackupWhenNotRunningDoesNothing() { + // Not started + final DatabaseBackupConfig config = new DatabaseBackupConfig("testdb"); + + // Should not throw + scheduler.triggerImmediateBackup("testdb", config); + + // No way to verify, but at least it doesn't throw + assertThat(scheduler.isRunning()).isFalse(); + } + + @Test + void invalidCronExpressionDoesNotSchedule() { + scheduler.start(); + + final DatabaseBackupConfig config = new DatabaseBackupConfig("testdb"); + config.setEnabled(true); + final DatabaseBackupConfig.ScheduleConfig schedule = new DatabaseBackupConfig.ScheduleConfig(); + schedule.setType(DatabaseBackupConfig.ScheduleConfig.Type.CRON); + schedule.setCronExpression("invalid cron"); + config.setSchedule(schedule); + + scheduler.scheduleBackup("testdb", config); + + // Should not be scheduled due to invalid cron + assertThat(scheduler.getScheduledCount()).isEqualTo(0); + } +} +``` + +**Step 2: Run tests to verify they pass** + +Run: `mvn test -pl server -Dtest=BackupSchedulerTest -q` +Expected: All tests PASS + +**Step 3: Commit** + +```bash +git add server/src/test/java/com/arcadedb/server/backup/BackupSchedulerTest.java +git commit -m "test: add BackupScheduler unit tests + +Cover scheduler lifecycle, frequency and cron scheduling, +cancel operations, and multiple database handling." +``` + +--- + +## Task 4: Run All Server Tests + +**Step 1: Run all server module tests** + +Run: `mvn test -pl server -q` +Expected: All tests PASS, BUILD SUCCESS + +**Step 2: Verify test counts** + +Run: `mvn test -pl server 2>&1 | grep "Tests run:"` +Expected: Increased test count from ~15 to ~70+ + +**Step 3: Final commit with summary** + +```bash +git add -A +git status +# If clean, no commit needed +# If there are changes: +git commit -m "test: server module test coverage complete" +``` + +--- + +## Summary + +This plan adds approximately **55 new unit tests** across 3 new test files: + +1. **DatabaseBackupConfigTest** (~40 tests) - Configuration parsing, validation, JSON serialization +2. **ExecutionResponseTest** (~10 tests) - HTTP response construction +3. **BackupSchedulerTest** (~15 tests) - Scheduler lifecycle and task management + +All tests are pure unit tests with no external dependencies required. diff --git a/docs/plans/2026-02-05-test-coverage-improvement-design.md b/docs/plans/2026-02-05-test-coverage-improvement-design.md new file mode 100644 index 0000000000..1367d668d1 --- /dev/null +++ b/docs/plans/2026-02-05-test-coverage-improvement-design.md @@ -0,0 +1,219 @@ +# Test Coverage Improvement Design + +## Overview + +Improve test coverage for ArcadeDB focusing on two areas with significant gaps: +1. **OpenCypher implementation** (~170 untested sources) +2. **Bolt protocol** (~25 untested sources) + +## Approach + +Balanced mix of unit tests and integration-style tests: +- **Unit tests**: For functions, mappers, serialization (fast, granular) +- **Integration tests**: For executor steps and protocol handlers (realistic) + +--- + +## Phase 1: Bolt Protocol Unit Tests + +### 1.1 BoltMessageTest (extend existing) + +Test all 17 message types for parsing and serialization. + +**Request messages to test**: +- `HelloMessage` (0x01) - user_agent, credentials, routing +- `RunMessage` (0x10) - query, parameters, database extraction +- `BeginMessage` (0x11) - transaction options +- `CommitMessage` (0x12) - simple marker +- `RollbackMessage` (0x13) - simple marker +- `DiscardMessage` (0x2F) - record count +- `PullMessage` (0x3F) - record count +- `ResetMessage` (0x0F) - connection reset +- `GoodbyeMessage` (0x02) - disconnect +- `LogonMessage` (0x6A) - re-authentication +- `LogoffMessage` (0x6B) - de-authentication +- `RouteMessage` (0x66) - routing info + +**Response messages to test**: +- `SuccessMessage` (0x70) - metadata +- `RecordMessage` (0x71) - data row +- `IgnoredMessage` (0x7E) - command ignored +- `FailureMessage` (0x7F) - error code/message + +### 1.2 BoltStructureMapperTest (new) + +Test type conversions in `BoltStructureMapper`: +- `toPackStreamValue()` - all supported types +- `ridToId()` - RID to numeric ID conversion +- `toNumber()` - numeric precision handling +- Date/time conversions to ISO strings +- Collection handling (List, Set, Map) +- Null and edge cases + +### 1.3 BoltChunkedIOTest (new) + +Test `BoltChunkedInput` and `BoltChunkedOutput`: +- Normal message chunking +- Large messages (>64KB, multiple chunks) +- Boundary conditions (exactly 65535 bytes) +- Empty messages +- Terminator handling (0x0000) + +--- + +## Phase 2: OpenCypher Function Unit Tests + +### 2.1 OpenCypherTextFunctionsTest (new) + +Test 27 text functions: +- `TextIndexOf`, `TextSplit`, `TextJoin` +- `TextReplace`, `TextRegexReplace` +- `TextCapitalize`, `TextCamelCase`, `TextSnakeCase` +- `TextLevenshteinDistance`, `TextJaroWinklerDistance` +- `TextTrim`, `TextLTrim`, `TextRTrim` +- `TextLeft`, `TextRight`, `TextSubstring` +- `TextReverse`, `TextRepeat` +- `TextStartsWith`, `TextEndsWith`, `TextContains` +- `TextToUpper`, `TextToLower` +- `TextBase64Encode`, `TextBase64Decode` +- `TextHexEncode`, `TextHexDecode` +- `TextFormat` + +### 2.2 OpenCypherMathFunctionsTest (new) + +Test 9 math functions: +- `MathSigmoid`, `MathTanh`, `MathCosh`, `MathSinh` +- `MathMaxLong`, `MathMaxDouble` +- `MathMinLong`, `MathMinDouble` +- `MathLog2` + +### 2.3 OpenCypherAggFunctionsTest (new) + +Test 11 aggregation functions: +- `AggFirst`, `AggLast`, `AggNth` +- `AggMedian`, `AggPercentiles` +- `AggStatistics` +- `AggMaxItems`, `AggMinItems` +- `AggProduct` +- `AggStDev`, `AggStDevP` + +### 2.4 OpenCypherDateFunctionsTest (new) + +Test 11 date functions: +- `DateCurrentTimestamp`, `DateNow` +- `DateFormat`, `DateParse` +- `DateAdd`, `DateSubtract` +- `DateConvert` +- `DateFromISO8601`, `DateToISO8601` +- `DateTruncate` +- `DateFields` (year, month, day, etc.) + +### 2.5 OpenCypherConvertFunctionsTest (new) + +Test 8 conversion functions: +- `ConvertToInteger`, `ConvertToFloat`, `ConvertToBoolean` +- `ConvertToString`, `ConvertToList` +- `ConvertToJson`, `ConvertFromJson` +- `ConvertToMap` + +### 2.6 OpenCypherUtilFunctionsTest (new) + +Test 10 utility functions: +- `UtilMd5`, `UtilSha1`, `UtilSha256`, `UtilSha512` +- `UtilCompress`, `UtilDecompress` +- `UtilSleep` +- `UtilValidate` +- `UtilRandomUuid` +- `UtilCoalesce` + +--- + +## Phase 3: OpenCypher Executor Steps + +### 3.1 OpenCypherMatchStepsTest (new) + +Test matching behavior: +- `MatchNodeStep` - label filtering, ID filtering, empty results +- `MatchRelationshipStep` - type filtering, direction handling +- `ExpandPathStep` - variable-length paths +- `ExpandIntoStep` - path expansion into existing nodes +- Optional match behavior + +### 3.2 OpenCypherFilterStepsTest (new) + +Test WHERE clause filtering: +- `FilterPropertiesStep` - property comparisons +- Null handling in comparisons +- Complex predicates (AND, OR, NOT) +- Pattern predicates + +### 3.3 OpenCypherAggregationStepsTest (new) + +Test aggregation operations: +- `AggregationStep` with COUNT, SUM, AVG, MIN, MAX +- GROUP BY with multiple fields +- DISTINCT aggregations +- Wrapped aggregations (HEAD(COLLECT(...))) +- Empty result set aggregation + +### 3.4 OpenCypherOrderingStepsTest (new) + +Test ordering operations: +- `OrderByStep` - single/multi-field sorting, ASC/DESC +- `LimitStep` - result limiting +- `SkipStep` - result skipping +- Combined SKIP + LIMIT + +### 3.5 OpenCypherProjectionStepsTest (new) + +Test projection operations: +- `ProjectReturnStep` - field selection, aliasing +- `UnwindStep` - list unwinding +- `WithStep` - intermediate result projection + +### 3.6 OpenCypherMutationStepsTest (new) + +Test mutation operations: +- `CreateStep` - node/relationship creation +- `SetStep` - property updates +- `DeleteStep` - node/relationship deletion +- `RemoveStep` - property/label removal +- `MergeStep` - MERGE with ON CREATE/ON MATCH + +--- + +## File Locations + +### Bolt Tests +- `bolt/src/test/java/com/arcadedb/bolt/BoltMessageTest.java` (extend) +- `bolt/src/test/java/com/arcadedb/bolt/BoltStructureMapperTest.java` (new) +- `bolt/src/test/java/com/arcadedb/bolt/BoltChunkedIOTest.java` (new) + +### OpenCypher Function Tests +- `engine/src/test/java/com/arcadedb/query/opencypher/functions/OpenCypherTextFunctionsTest.java` +- `engine/src/test/java/com/arcadedb/query/opencypher/functions/OpenCypherMathFunctionsTest.java` +- `engine/src/test/java/com/arcadedb/query/opencypher/functions/OpenCypherAggFunctionsTest.java` +- `engine/src/test/java/com/arcadedb/query/opencypher/functions/OpenCypherDateFunctionsTest.java` +- `engine/src/test/java/com/arcadedb/query/opencypher/functions/OpenCypherConvertFunctionsTest.java` +- `engine/src/test/java/com/arcadedb/query/opencypher/functions/OpenCypherUtilFunctionsTest.java` + +### OpenCypher Executor Tests +- `engine/src/test/java/com/arcadedb/query/opencypher/OpenCypherMatchStepsTest.java` +- `engine/src/test/java/com/arcadedb/query/opencypher/OpenCypherFilterStepsTest.java` +- `engine/src/test/java/com/arcadedb/query/opencypher/OpenCypherAggregationStepsTest.java` +- `engine/src/test/java/com/arcadedb/query/opencypher/OpenCypherOrderingStepsTest.java` +- `engine/src/test/java/com/arcadedb/query/opencypher/OpenCypherProjectionStepsTest.java` +- `engine/src/test/java/com/arcadedb/query/opencypher/OpenCypherMutationStepsTest.java` + +--- + +## Expected Coverage Improvement + +| Area | Before | After | +|------|--------|-------| +| Bolt messages | ~3 tests | ~20 tests | +| Bolt structures | ~5 tests | ~15 tests | +| OpenCypher functions | 0 direct tests | ~80 tests | +| OpenCypher steps | indirect only | ~30 focused tests | + +**Total new tests**: ~140+ tests diff --git a/engine/src/test/java/com/arcadedb/query/opencypher/functions/OpenCypherConvertFunctionsTest.java b/engine/src/test/java/com/arcadedb/query/opencypher/functions/OpenCypherConvertFunctionsTest.java new file mode 100644 index 0000000000..dca3eec2db --- /dev/null +++ b/engine/src/test/java/com/arcadedb/query/opencypher/functions/OpenCypherConvertFunctionsTest.java @@ -0,0 +1,343 @@ +/* + * Copyright 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.query.opencypher.functions; + +import com.arcadedb.query.opencypher.functions.convert.*; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.within; + +/** + * Unit tests for OpenCypher convert functions. + */ +class OpenCypherConvertFunctionsTest { + + // ============ ConvertToInteger tests ============ + + @Test + void convertToIntegerFromNumber() { + final ConvertToInteger fn = new ConvertToInteger(); + assertThat(fn.getName()).isEqualTo("convert.toInteger"); + + assertThat(fn.execute(new Object[]{42}, null)).isEqualTo(42L); + assertThat(fn.execute(new Object[]{42L}, null)).isEqualTo(42L); + assertThat(fn.execute(new Object[]{42.9}, null)).isEqualTo(42L); // Truncates + assertThat(fn.execute(new Object[]{42.1f}, null)).isEqualTo(42L); + } + + @Test + void convertToIntegerFromBoolean() { + final ConvertToInteger fn = new ConvertToInteger(); + + assertThat(fn.execute(new Object[]{true}, null)).isEqualTo(1L); + assertThat(fn.execute(new Object[]{false}, null)).isEqualTo(0L); + } + + @Test + void convertToIntegerFromString() { + final ConvertToInteger fn = new ConvertToInteger(); + + assertThat(fn.execute(new Object[]{"42"}, null)).isEqualTo(42L); + assertThat(fn.execute(new Object[]{" 42 "}, null)).isEqualTo(42L); // Trimmed + assertThat(fn.execute(new Object[]{"42.9"}, null)).isEqualTo(42L); // Parsed as double, then truncated + assertThat(fn.execute(new Object[]{"invalid"}, null)).isNull(); + } + + @Test + void convertToIntegerNullHandling() { + final ConvertToInteger fn = new ConvertToInteger(); + assertThat(fn.execute(new Object[]{null}, null)).isNull(); + } + + // ============ ConvertToFloat tests ============ + + @Test + void convertToFloatFromNumber() { + final ConvertToFloat fn = new ConvertToFloat(); + assertThat(fn.getName()).isEqualTo("convert.toFloat"); + + assertThat((Double) fn.execute(new Object[]{42}, null)).isCloseTo(42.0, within(0.001)); + assertThat((Double) fn.execute(new Object[]{42.5}, null)).isCloseTo(42.5, within(0.001)); + } + + @Test + void convertToFloatFromString() { + final ConvertToFloat fn = new ConvertToFloat(); + + assertThat((Double) fn.execute(new Object[]{"42.5"}, null)).isCloseTo(42.5, within(0.001)); + assertThat((Double) fn.execute(new Object[]{" 42.5 "}, null)).isCloseTo(42.5, within(0.001)); + assertThat(fn.execute(new Object[]{"invalid"}, null)).isNull(); + } + + @Test + void convertToFloatNullHandling() { + final ConvertToFloat fn = new ConvertToFloat(); + assertThat(fn.execute(new Object[]{null}, null)).isNull(); + } + + // ============ ConvertToBoolean tests ============ + + @Test + void convertToBooleanFromBoolean() { + final ConvertToBoolean fn = new ConvertToBoolean(); + assertThat(fn.getName()).isEqualTo("convert.toBoolean"); + + assertThat(fn.execute(new Object[]{true}, null)).isEqualTo(true); + assertThat(fn.execute(new Object[]{false}, null)).isEqualTo(false); + } + + @Test + void convertToBooleanFromString() { + final ConvertToBoolean fn = new ConvertToBoolean(); + + assertThat(fn.execute(new Object[]{"true"}, null)).isEqualTo(true); + assertThat(fn.execute(new Object[]{"TRUE"}, null)).isEqualTo(true); + assertThat(fn.execute(new Object[]{"false"}, null)).isEqualTo(false); + assertThat(fn.execute(new Object[]{"FALSE"}, null)).isEqualTo(false); + assertThat(fn.execute(new Object[]{"yes"}, null)).isEqualTo(true); + assertThat(fn.execute(new Object[]{"no"}, null)).isEqualTo(false); + } + + @Test + void convertToBooleanFromNumber() { + final ConvertToBoolean fn = new ConvertToBoolean(); + + assertThat(fn.execute(new Object[]{1}, null)).isEqualTo(true); + assertThat(fn.execute(new Object[]{0}, null)).isEqualTo(false); + assertThat(fn.execute(new Object[]{42}, null)).isEqualTo(true); // Non-zero is true + } + + @Test + void convertToBooleanNullHandling() { + final ConvertToBoolean fn = new ConvertToBoolean(); + assertThat(fn.execute(new Object[]{null}, null)).isNull(); + } + + // ============ ConvertToJson tests ============ + + @Test + void convertToJsonFromPrimitives() { + final ConvertToJson fn = new ConvertToJson(); + assertThat(fn.getName()).isEqualTo("convert.toJson"); + + // Primitives are returned as their string representation + assertThat(fn.execute(new Object[]{"hello"}, null)).isEqualTo("hello"); + assertThat(fn.execute(new Object[]{42}, null)).isEqualTo("42"); + assertThat(fn.execute(new Object[]{true}, null)).isEqualTo("true"); + assertThat(fn.execute(new Object[]{null}, null)).isEqualTo("null"); + } + + @Test + void convertToJsonFromMap() { + final ConvertToJson fn = new ConvertToJson(); + + final Map map = new LinkedHashMap<>(); + map.put("name", "Alice"); + map.put("age", 30); + + final String result = (String) fn.execute(new Object[]{map}, null); + assertThat(result).contains("\"name\""); + assertThat(result).contains("\"Alice\""); + assertThat(result).contains("30"); + } + + @Test + void convertToJsonFromList() { + final ConvertToJson fn = new ConvertToJson(); + + final List list = Arrays.asList(1, 2, 3); + final String result = (String) fn.execute(new Object[]{list}, null); + assertThat(result).isEqualTo("[1,2,3]"); + } + + // ============ ConvertFromJsonMap tests ============ + + @Test + void convertFromJsonMapBasic() { + final ConvertFromJsonMap fn = new ConvertFromJsonMap(); + assertThat(fn.getName()).isEqualTo("convert.fromJsonMap"); + + @SuppressWarnings("unchecked") + final Map result = (Map) fn.execute( + new Object[]{"{\"name\": \"Alice\", \"age\": 30}"}, null); + + assertThat(result).containsEntry("name", "Alice"); + assertThat(result).containsEntry("age", 30); + } + + @Test + void convertFromJsonMapNullHandling() { + final ConvertFromJsonMap fn = new ConvertFromJsonMap(); + assertThat(fn.execute(new Object[]{null}, null)).isNull(); + } + + // ============ ConvertFromJsonList tests ============ + + @Test + void convertFromJsonListBasic() { + final ConvertFromJsonList fn = new ConvertFromJsonList(); + assertThat(fn.getName()).isEqualTo("convert.fromJsonList"); + + @SuppressWarnings("unchecked") + final List result = (List) fn.execute(new Object[]{"[1, 2, 3]"}, null); + assertThat(result).containsExactly(1, 2, 3); + } + + @Test + void convertFromJsonListNullHandling() { + final ConvertFromJsonList fn = new ConvertFromJsonList(); + assertThat(fn.execute(new Object[]{null}, null)).isNull(); + } + + // ============ ConvertToList tests ============ + + @Test + void convertToListFromCollection() { + final ConvertToList fn = new ConvertToList(); + assertThat(fn.getName()).isEqualTo("convert.toList"); + + // From List - returns a copy + @SuppressWarnings("unchecked") + final List result = (List) fn.execute(new Object[]{Arrays.asList(1, 2, 3)}, null); + assertThat(result).containsExactly(1, 2, 3); + } + + @Test + void convertToListFromSet() { + final ConvertToList fn = new ConvertToList(); + + @SuppressWarnings("unchecked") + final List result = (List) fn.execute(new Object[]{Set.of(1, 2, 3)}, null); + assertThat(result).hasSize(3); + assertThat(result).containsExactlyInAnyOrder(1, 2, 3); + } + + @Test + void convertToListFromSingleValue() { + final ConvertToList fn = new ConvertToList(); + + // Single value is wrapped in a list + @SuppressWarnings("unchecked") + final List result = (List) fn.execute(new Object[]{"single"}, null); + assertThat(result).containsExactly("single"); + } + + @Test + void convertToListFromArrayWrapsAsElement() { + final ConvertToList fn = new ConvertToList(); + + // Object[] is treated as a single value (not a collection), so it gets wrapped + @SuppressWarnings("unchecked") + final List result = (List) fn.execute(new Object[]{new Object[]{1, 2, 3}}, null); + // The array is wrapped as a single element + assertThat(result).hasSize(1); + assertThat(result.get(0)).isInstanceOf(Object[].class); + } + + @Test + void convertToListNullHandling() { + final ConvertToList fn = new ConvertToList(); + assertThat(fn.execute(new Object[]{null}, null)).isNull(); + } + + // ============ ConvertToSet tests ============ + + @Test + void convertToSetFromListRemovesDuplicates() { + final ConvertToSet fn = new ConvertToSet(); + assertThat(fn.getName()).isEqualTo("convert.toSet"); + + // Note: ConvertToSet returns a List (deduped), not a Set + @SuppressWarnings("unchecked") + final List result = (List) fn.execute(new Object[]{Arrays.asList(1, 2, 2, 3, 3, 3)}, null); + assertThat(result).hasSize(3); + assertThat(result).containsExactlyInAnyOrder(1, 2, 3); + } + + @Test + void convertToSetNullHandling() { + final ConvertToSet fn = new ConvertToSet(); + assertThat(fn.execute(new Object[]{null}, null)).isNull(); + } + + @Test + void convertToSetRequiresCollection() { + final ConvertToSet fn = new ConvertToSet(); + + assertThatThrownBy(() -> fn.execute(new Object[]{"not a collection"}, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("requires a list or collection"); + } + + // ============ ConvertToMap tests ============ + + @Test + void convertToMapFromMap() { + final ConvertToMap fn = new ConvertToMap(); + assertThat(fn.getName()).isEqualTo("convert.toMap"); + + final Map input = new HashMap<>(); + input.put("a", 1); + input.put("b", 2); + + @SuppressWarnings("unchecked") + final Map result = (Map) fn.execute(new Object[]{input}, null); + assertThat(result).containsEntry("a", 1); + assertThat(result).containsEntry("b", 2); + } + + @Test + void convertToMapNullHandling() { + final ConvertToMap fn = new ConvertToMap(); + assertThat(fn.execute(new Object[]{null}, null)).isNull(); + } + + @Test + void convertToMapInvalidInput() { + final ConvertToMap fn = new ConvertToMap(); + + assertThatThrownBy(() -> fn.execute(new Object[]{"not a map"}, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot convert"); + } + + // ============ Metadata tests ============ + + @Test + void convertFunctionsMetadata() { + final ConvertToInteger intFn = new ConvertToInteger(); + assertThat(intFn.getMinArgs()).isEqualTo(1); + assertThat(intFn.getMaxArgs()).isEqualTo(1); + assertThat(intFn.getDescription()).isNotEmpty(); + + final ConvertToJson jsonFn = new ConvertToJson(); + assertThat(jsonFn.getMinArgs()).isEqualTo(1); + assertThat(jsonFn.getMaxArgs()).isEqualTo(1); + assertThat(jsonFn.getDescription()).contains("JSON"); + } +} diff --git a/engine/src/test/java/com/arcadedb/query/opencypher/functions/OpenCypherDateFunctionsTest.java b/engine/src/test/java/com/arcadedb/query/opencypher/functions/OpenCypherDateFunctionsTest.java new file mode 100644 index 0000000000..22e409f564 --- /dev/null +++ b/engine/src/test/java/com/arcadedb/query/opencypher/functions/OpenCypherDateFunctionsTest.java @@ -0,0 +1,293 @@ +/* + * Copyright 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.query.opencypher.functions; + +import com.arcadedb.query.opencypher.functions.date.*; +import org.junit.jupiter.api.Test; + +import java.time.ZoneId; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for OpenCypher date functions. + */ +class OpenCypherDateFunctionsTest { + + // ============ DateCurrentTimestamp tests ============ + + @Test + void dateCurrentTimestampBasic() { + final DateCurrentTimestamp fn = new DateCurrentTimestamp(); + assertThat(fn.getName()).isEqualTo("date.currentTimestamp"); + + final long before = System.currentTimeMillis(); + final long result = (Long) fn.execute(new Object[]{}, null); + final long after = System.currentTimeMillis(); + + assertThat(result).isBetween(before, after); + } + + @Test + void dateCurrentTimestampMetadata() { + final DateCurrentTimestamp fn = new DateCurrentTimestamp(); + + assertThat(fn.getMinArgs()).isEqualTo(0); + assertThat(fn.getMaxArgs()).isEqualTo(0); + assertThat(fn.getDescription()).contains("timestamp"); + } + + // ============ DateFormat tests ============ + + @Test + void dateFormatBasic() { + final DateFormat fn = new DateFormat(); + assertThat(fn.getName()).isEqualTo("date.format"); + + // Format a known timestamp + final long timestamp = 1704067200000L; // 2024-01-01 00:00:00 UTC + final String result = (String) fn.execute(new Object[]{timestamp}, null); + + assertThat(result).isNotEmpty(); + } + + @Test + void dateFormatWithPattern() { + final DateFormat fn = new DateFormat(); + + // Format with specific pattern + final long timestamp = 1704067200000L; // 2024-01-01 00:00:00 UTC + final String result = (String) fn.execute(new Object[]{timestamp, "ms", "yyyy-MM-dd"}, null); + + // Note: Result depends on system timezone, but should contain date parts + assertThat(result).matches("\\d{4}-\\d{2}-\\d{2}"); + } + + @Test + void dateFormatNullHandling() { + final DateFormat fn = new DateFormat(); + assertThat(fn.execute(new Object[]{null}, null)).isNull(); + } + + // ============ DateSystemTimezone tests ============ + + @Test + void dateSystemTimezoneBasic() { + final DateSystemTimezone fn = new DateSystemTimezone(); + assertThat(fn.getName()).isEqualTo("date.systemTimezone"); + + final String result = (String) fn.execute(new Object[]{}, null); + + assertThat(result).isEqualTo(ZoneId.systemDefault().getId()); + } + + // ============ DateField tests ============ + + @Test + void dateFieldBasic() { + final DateField fn = new DateField(); + assertThat(fn.getName()).isEqualTo("date.field"); + + // DateField takes a timestamp, not a date string + // Use a timestamp for 2024-01-15 (UTC) + final long timestamp = 1705276200000L; // 2024-01-15 at some hour UTC + final Object result = fn.execute(new Object[]{timestamp, "year"}, null); + assertThat(result).isEqualTo(2024L); + } + + @Test + void dateFieldMonth() { + final DateField fn = new DateField(); + + // Use a timestamp for June 2024 + final long timestamp = 1718438400000L; // 2024-06-15 00:00:00 UTC + final Object result = fn.execute(new Object[]{timestamp, "month"}, null); + assertThat(result).isEqualTo(6L); + } + + @Test + void dateFieldNullHandling() { + final DateField fn = new DateField(); + assertThat(fn.execute(new Object[]{null, "year"}, null)).isNull(); + } + + // ============ DateFields tests ============ + + @Test + void dateFieldsBasic() { + final DateFields fn = new DateFields(); + assertThat(fn.getName()).isEqualTo("date.fields"); + + // DateFields expects a date string, not a timestamp + @SuppressWarnings("unchecked") + final Map result = (Map) fn.execute(new Object[]{"2024-01-15T10:30:45"}, null); + + assertThat(result).containsEntry("year", 2024L); + assertThat(result).containsEntry("month", 1L); + assertThat(result).containsEntry("day", 15L); + assertThat(result).containsEntry("hour", 10L); + assertThat(result).containsEntry("minute", 30L); + assertThat(result).containsEntry("second", 45L); + } + + @Test + void dateFieldsNullHandling() { + final DateFields fn = new DateFields(); + assertThat(fn.execute(new Object[]{null}, null)).isNull(); + } + + // ============ DateAdd tests ============ + + @Test + void dateAddBasic() { + final DateAdd fn = new DateAdd(); + assertThat(fn.getName()).isEqualTo("date.add"); + + final long timestamp = 1704067200000L; // 2024-01-01 00:00:00 UTC + + // DateAdd takes (timestamp, value, unit) + // Add 1 day (d unit, 1 value) + final long result = (Long) fn.execute(new Object[]{timestamp, 1, "d"}, null); + assertThat(result).isEqualTo(timestamp + 86400000L); + } + + @Test + void dateAddHours() { + final DateAdd fn = new DateAdd(); + + final long timestamp = 1704067200000L; + + // Add 2 hours + final long result = (Long) fn.execute(new Object[]{timestamp, 2, "h"}, null); + assertThat(result).isEqualTo(timestamp + 2 * 3600000L); + } + + @Test + void dateAddNullHandling() { + final DateAdd fn = new DateAdd(); + assertThat(fn.execute(new Object[]{null, 1, "d"}, null)).isNull(); + } + + // ============ DateConvert tests ============ + + @Test + void dateConvertBasic() { + final DateConvert fn = new DateConvert(); + assertThat(fn.getName()).isEqualTo("date.convert"); + + // Convert from ms to s + final long msTimestamp = 1704067200000L; + final long result = (Long) fn.execute(new Object[]{msTimestamp, "ms", "s"}, null); + assertThat(result).isEqualTo(1704067200L); + } + + @Test + void dateConvertNullHandling() { + final DateConvert fn = new DateConvert(); + assertThat(fn.execute(new Object[]{null, "ms", "s"}, null)).isNull(); + } + + // ============ DateToISO8601 tests ============ + + @Test + void dateToISO8601Basic() { + final DateToISO8601 fn = new DateToISO8601(); + assertThat(fn.getName()).isEqualTo("date.toISO8601"); + + final long timestamp = 1704067200000L; // 2024-01-01 00:00:00 UTC + final String result = (String) fn.execute(new Object[]{timestamp}, null); + + // Should contain ISO8601 format elements + assertThat(result).contains("2024"); + assertThat(result).contains("T"); + } + + @Test + void dateToISO8601NullHandling() { + final DateToISO8601 fn = new DateToISO8601(); + assertThat(fn.execute(new Object[]{null}, null)).isNull(); + } + + // ============ DateFromISO8601 tests ============ + + @Test + void dateFromISO8601Basic() { + final DateFromISO8601 fn = new DateFromISO8601(); + assertThat(fn.getName()).isEqualTo("date.fromISO8601"); + + final long result = (Long) fn.execute(new Object[]{"2024-01-01T00:00:00Z"}, null); + + // Should return timestamp for 2024-01-01 + assertThat(result).isEqualTo(1704067200000L); + } + + @Test + void dateFromISO8601NullHandling() { + final DateFromISO8601 fn = new DateFromISO8601(); + assertThat(fn.execute(new Object[]{null}, null)).isNull(); + } + + // ============ DateParse tests ============ + + @Test + void dateParseBasic() { + final DateParse fn = new DateParse(); + assertThat(fn.getName()).isEqualTo("date.parse"); + + // DateParse uses ISO datetime format by default + // Parse with unit (2nd arg) and optional format (3rd arg) + final Object result = fn.execute(new Object[]{"2024-01-15T10:30:00", "ms"}, null); + + assertThat(result).isNotNull(); + assertThat((Long) result).isGreaterThan(0L); + } + + @Test + void dateParseWithCustomFormat() { + final DateParse fn = new DateParse(); + + // With custom format - need to match the format pattern + final Object result = fn.execute(new Object[]{"2024-01-15 10:30:00", "ms", "yyyy-MM-dd HH:mm:ss"}, null); + + assertThat(result).isNotNull(); + assertThat((Long) result).isGreaterThan(0L); + } + + @Test + void dateParseNullHandling() { + final DateParse fn = new DateParse(); + assertThat(fn.execute(new Object[]{null, "ms"}, null)).isNull(); + } + + // ============ Metadata tests ============ + + @Test + void dateFunctionsMetadata() { + final DateFormat formatFn = new DateFormat(); + assertThat(formatFn.getMinArgs()).isEqualTo(1); + assertThat(formatFn.getMaxArgs()).isEqualTo(3); + assertThat(formatFn.getDescription()).isNotEmpty(); + + final DateAdd addFn = new DateAdd(); + assertThat(addFn.getMinArgs()).isEqualTo(3); + assertThat(addFn.getMaxArgs()).isEqualTo(3); + assertThat(addFn.getDescription()).isNotEmpty(); + } +} diff --git a/engine/src/test/java/com/arcadedb/query/opencypher/functions/OpenCypherMathFunctionsTest.java b/engine/src/test/java/com/arcadedb/query/opencypher/functions/OpenCypherMathFunctionsTest.java new file mode 100644 index 0000000000..bced45512a --- /dev/null +++ b/engine/src/test/java/com/arcadedb/query/opencypher/functions/OpenCypherMathFunctionsTest.java @@ -0,0 +1,242 @@ +/* + * Copyright 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.query.opencypher.functions; + +import com.arcadedb.query.opencypher.functions.math.*; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +/** + * Unit tests for OpenCypher math functions. + */ +class OpenCypherMathFunctionsTest { + + // ============ MathSigmoid tests ============ + + @Test + void mathSigmoidBasic() { + final MathSigmoid fn = new MathSigmoid(); + assertThat(fn.getName()).isEqualTo("math.sigmoid"); + + // sigmoid(0) = 0.5 + assertThat((Double) fn.execute(new Object[]{0}, null)).isCloseTo(0.5, within(0.0001)); + + // sigmoid at large positive value approaches 1 + assertThat((Double) fn.execute(new Object[]{10}, null)).isCloseTo(1.0, within(0.001)); + + // sigmoid at large negative value approaches 0 + assertThat((Double) fn.execute(new Object[]{-10}, null)).isCloseTo(0.0, within(0.001)); + } + + @Test + void mathSigmoidNullHandling() { + final MathSigmoid fn = new MathSigmoid(); + assertThat(fn.execute(new Object[]{null}, null)).isNull(); + } + + @Test + void mathSigmoidMetadata() { + final MathSigmoid fn = new MathSigmoid(); + + assertThat(fn.getMinArgs()).isEqualTo(1); + assertThat(fn.getMaxArgs()).isEqualTo(1); + assertThat(fn.getDescription()).contains("sigmoid"); + } + + // ============ MathSigmoidPrime tests ============ + + @Test + void mathSigmoidPrimeBasic() { + final MathSigmoidPrime fn = new MathSigmoidPrime(); + assertThat(fn.getName()).isEqualTo("math.sigmoidPrime"); + + // sigmoidPrime(0) = 0.25 (derivative at x=0) + assertThat((Double) fn.execute(new Object[]{0}, null)).isCloseTo(0.25, within(0.0001)); + + // sigmoidPrime approaches 0 at extreme values + assertThat((Double) fn.execute(new Object[]{10}, null)).isCloseTo(0.0, within(0.001)); + assertThat((Double) fn.execute(new Object[]{-10}, null)).isCloseTo(0.0, within(0.001)); + } + + @Test + void mathSigmoidPrimeNullHandling() { + final MathSigmoidPrime fn = new MathSigmoidPrime(); + assertThat(fn.execute(new Object[]{null}, null)).isNull(); + } + + // ============ MathTanh tests ============ + + @Test + void mathTanhBasic() { + final MathTanh fn = new MathTanh(); + assertThat(fn.getName()).isEqualTo("math.tanh"); + + // tanh(0) = 0 + assertThat((Double) fn.execute(new Object[]{0}, null)).isCloseTo(0.0, within(0.0001)); + + // tanh approaches 1 at large positive value + assertThat((Double) fn.execute(new Object[]{10}, null)).isCloseTo(1.0, within(0.001)); + + // tanh approaches -1 at large negative value + assertThat((Double) fn.execute(new Object[]{-10}, null)).isCloseTo(-1.0, within(0.001)); + } + + @Test + void mathTanhNullHandling() { + final MathTanh fn = new MathTanh(); + assertThat(fn.execute(new Object[]{null}, null)).isNull(); + } + + // ============ MathCosh tests ============ + + @Test + void mathCoshBasic() { + final MathCosh fn = new MathCosh(); + assertThat(fn.getName()).isEqualTo("math.cosh"); + + // cosh(0) = 1 + assertThat((Double) fn.execute(new Object[]{0}, null)).isCloseTo(1.0, within(0.0001)); + + // cosh is symmetric: cosh(x) = cosh(-x) + final double coshPositive = (Double) fn.execute(new Object[]{2}, null); + final double coshNegative = (Double) fn.execute(new Object[]{-2}, null); + assertThat(coshPositive).isCloseTo(coshNegative, within(0.0001)); + } + + @Test + void mathCoshNullHandling() { + final MathCosh fn = new MathCosh(); + assertThat(fn.execute(new Object[]{null}, null)).isNull(); + } + + // ============ MathSinh tests ============ + + @Test + void mathSinhBasic() { + final MathSinh fn = new MathSinh(); + assertThat(fn.getName()).isEqualTo("math.sinh"); + + // sinh(0) = 0 + assertThat((Double) fn.execute(new Object[]{0}, null)).isCloseTo(0.0, within(0.0001)); + + // sinh is antisymmetric: sinh(-x) = -sinh(x) + final double sinhPositive = (Double) fn.execute(new Object[]{2}, null); + final double sinhNegative = (Double) fn.execute(new Object[]{-2}, null); + assertThat(sinhPositive).isCloseTo(-sinhNegative, within(0.0001)); + } + + @Test + void mathSinhNullHandling() { + final MathSinh fn = new MathSinh(); + assertThat(fn.execute(new Object[]{null}, null)).isNull(); + } + + // ============ MathMaxLong tests ============ + + @Test + void mathMaxLongBasic() { + final MathMaxLong fn = new MathMaxLong(); + assertThat(fn.getName()).isEqualTo("math.maxLong"); + + assertThat(fn.execute(new Object[]{}, null)).isEqualTo(Long.MAX_VALUE); + } + + @Test + void mathMaxLongMetadata() { + final MathMaxLong fn = new MathMaxLong(); + + assertThat(fn.getMinArgs()).isEqualTo(0); + assertThat(fn.getMaxArgs()).isEqualTo(0); + assertThat(fn.getDescription()).contains("maximum Long"); + } + + // ============ MathMinLong tests ============ + + @Test + void mathMinLongBasic() { + final MathMinLong fn = new MathMinLong(); + assertThat(fn.getName()).isEqualTo("math.minLong"); + + assertThat(fn.execute(new Object[]{}, null)).isEqualTo(Long.MIN_VALUE); + } + + // ============ MathMaxDouble tests ============ + + @Test + void mathMaxDoubleBasic() { + final MathMaxDouble fn = new MathMaxDouble(); + assertThat(fn.getName()).isEqualTo("math.maxDouble"); + + assertThat(fn.execute(new Object[]{}, null)).isEqualTo(Double.MAX_VALUE); + } + + // ============ Input type conversion tests ============ + + @Test + void mathFunctionsAcceptDifferentNumberTypes() { + final MathSigmoid fn = new MathSigmoid(); + + // Integer + assertThat(fn.execute(new Object[]{0}, null)).isNotNull(); + + // Long + assertThat(fn.execute(new Object[]{0L}, null)).isNotNull(); + + // Double + assertThat(fn.execute(new Object[]{0.0}, null)).isNotNull(); + + // Float + assertThat(fn.execute(new Object[]{0.0f}, null)).isNotNull(); + + // String (should be parsed) + assertThat(fn.execute(new Object[]{"0"}, null)).isNotNull(); + } + + // ============ Mathematical identity tests ============ + + @Test + void hyperbolicIdentity() { + // cosh^2(x) - sinh^2(x) = 1 + final MathCosh coshFn = new MathCosh(); + final MathSinh sinhFn = new MathSinh(); + + final double x = 1.5; + final double coshX = (Double) coshFn.execute(new Object[]{x}, null); + final double sinhX = (Double) sinhFn.execute(new Object[]{x}, null); + + assertThat(coshX * coshX - sinhX * sinhX).isCloseTo(1.0, within(0.0001)); + } + + @Test + void tanhIdentity() { + // tanh(x) = sinh(x) / cosh(x) + final MathTanh tanhFn = new MathTanh(); + final MathCosh coshFn = new MathCosh(); + final MathSinh sinhFn = new MathSinh(); + + final double x = 1.5; + final double tanhX = (Double) tanhFn.execute(new Object[]{x}, null); + final double coshX = (Double) coshFn.execute(new Object[]{x}, null); + final double sinhX = (Double) sinhFn.execute(new Object[]{x}, null); + + assertThat(tanhX).isCloseTo(sinhX / coshX, within(0.0001)); + } +} diff --git a/engine/src/test/java/com/arcadedb/query/opencypher/functions/OpenCypherTextFunctionsTest.java b/engine/src/test/java/com/arcadedb/query/opencypher/functions/OpenCypherTextFunctionsTest.java new file mode 100644 index 0000000000..b13fda4ec5 --- /dev/null +++ b/engine/src/test/java/com/arcadedb/query/opencypher/functions/OpenCypherTextFunctionsTest.java @@ -0,0 +1,468 @@ +/* + * Copyright 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.query.opencypher.functions; + +import com.arcadedb.query.opencypher.functions.text.*; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.within; + +/** + * Unit tests for OpenCypher text functions. + */ +class OpenCypherTextFunctionsTest { + + // ============ TextIndexOf tests ============ + + @Test + void textIndexOfBasic() { + final TextIndexOf fn = new TextIndexOf(); + assertThat(fn.getName()).isEqualTo("text.indexOf"); + + assertThat(fn.execute(new Object[]{"hello world", "world"}, null)).isEqualTo(6L); + assertThat(fn.execute(new Object[]{"hello world", "hello"}, null)).isEqualTo(0L); + assertThat(fn.execute(new Object[]{"hello world", "x"}, null)).isEqualTo(-1L); + } + + @Test + void textIndexOfWithStartPosition() { + final TextIndexOf fn = new TextIndexOf(); + + assertThat(fn.execute(new Object[]{"hello hello", "hello", 1}, null)).isEqualTo(6L); + assertThat(fn.execute(new Object[]{"hello hello", "hello", 0}, null)).isEqualTo(0L); + assertThat(fn.execute(new Object[]{"hello hello", "hello", 10}, null)).isEqualTo(-1L); + } + + @Test + void textIndexOfNullHandling() { + final TextIndexOf fn = new TextIndexOf(); + + assertThat(fn.execute(new Object[]{null, "test"}, null)).isNull(); + assertThat(fn.execute(new Object[]{"test", null}, null)).isNull(); + } + + // ============ TextSplit tests ============ + + @Test + void textSplitBasic() { + final TextSplit fn = new TextSplit(); + assertThat(fn.getName()).isEqualTo("text.split"); + + @SuppressWarnings("unchecked") + final List result = (List) fn.execute(new Object[]{"a,b,c", ","}, null); + assertThat(result).containsExactly("a", "b", "c"); + } + + @Test + void textSplitEmptyDelimiter() { + final TextSplit fn = new TextSplit(); + + @SuppressWarnings("unchecked") + final List result = (List) fn.execute(new Object[]{"abc", ""}, null); + assertThat(result).containsExactly("a", "b", "c"); + } + + @Test + void textSplitNullString() { + final TextSplit fn = new TextSplit(); + assertThat(fn.execute(new Object[]{null, ","}, null)).isNull(); + } + + @Test + void textSplitSpecialCharDelimiter() { + final TextSplit fn = new TextSplit(); + + // Regex special characters should be treated as literals + @SuppressWarnings("unchecked") + final List result = (List) fn.execute(new Object[]{"a.b.c", "."}, null); + assertThat(result).containsExactly("a", "b", "c"); + } + + // ============ TextJoin tests ============ + + @Test + void textJoinBasic() { + final TextJoin fn = new TextJoin(); + assertThat(fn.getName()).isEqualTo("text.join"); + + final Object result = fn.execute(new Object[]{Arrays.asList("a", "b", "c"), ","}, null); + assertThat(result).isEqualTo("a,b,c"); + } + + @Test + void textJoinWithNullElements() { + final TextJoin fn = new TextJoin(); + + final Object result = fn.execute(new Object[]{Arrays.asList("a", null, "c"), "-"}, null); + assertThat(result).isEqualTo("a--c"); + } + + @Test + void textJoinNullList() { + final TextJoin fn = new TextJoin(); + assertThat(fn.execute(new Object[]{null, ","}, null)).isNull(); + } + + @Test + void textJoinNullDelimiter() { + final TextJoin fn = new TextJoin(); + + final Object result = fn.execute(new Object[]{Arrays.asList("a", "b", "c"), null}, null); + assertThat(result).isEqualTo("abc"); + } + + @Test + void textJoinInvalidFirstArg() { + final TextJoin fn = new TextJoin(); + + assertThatThrownBy(() -> fn.execute(new Object[]{"not a list", ","}, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must be a list"); + } + + // ============ TextCapitalize tests ============ + + @Test + void textCapitalizeBasic() { + final TextCapitalize fn = new TextCapitalize(); + assertThat(fn.getName()).isEqualTo("text.capitalize"); + + assertThat(fn.execute(new Object[]{"hello"}, null)).isEqualTo("Hello"); + assertThat(fn.execute(new Object[]{"HELLO"}, null)).isEqualTo("HELLO"); + assertThat(fn.execute(new Object[]{"hello world"}, null)).isEqualTo("Hello world"); + } + + @Test + void textCapitalizeEdgeCases() { + final TextCapitalize fn = new TextCapitalize(); + + assertThat(fn.execute(new Object[]{null}, null)).isNull(); + assertThat(fn.execute(new Object[]{""}, null)).isEqualTo(""); + assertThat(fn.execute(new Object[]{"a"}, null)).isEqualTo("A"); + } + + // ============ TextLevenshteinDistance tests ============ + + @Test + void textLevenshteinDistanceBasic() { + final TextLevenshteinDistance fn = new TextLevenshteinDistance(); + assertThat(fn.getName()).isEqualTo("text.levenshteinDistance"); + + assertThat(fn.execute(new Object[]{"kitten", "sitting"}, null)).isEqualTo(3L); + assertThat(fn.execute(new Object[]{"hello", "hello"}, null)).isEqualTo(0L); + assertThat(fn.execute(new Object[]{"", "abc"}, null)).isEqualTo(3L); + assertThat(fn.execute(new Object[]{"abc", ""}, null)).isEqualTo(3L); + } + + @Test + void textLevenshteinDistanceNullHandling() { + final TextLevenshteinDistance fn = new TextLevenshteinDistance(); + + assertThat(fn.execute(new Object[]{null, "test"}, null)).isNull(); + assertThat(fn.execute(new Object[]{"test", null}, null)).isNull(); + } + + @Test + void textLevenshteinDistanceStaticMethod() { + // Test the static method directly + assertThat(TextLevenshteinDistance.levenshteinDistance("cat", "hat")).isEqualTo(1); + assertThat(TextLevenshteinDistance.levenshteinDistance("book", "back")).isEqualTo(2); + assertThat(TextLevenshteinDistance.levenshteinDistance("", "")).isEqualTo(0); + } + + // ============ TextJaroWinklerDistance tests ============ + + @Test + void textJaroWinklerDistanceBasic() { + final TextJaroWinklerDistance fn = new TextJaroWinklerDistance(); + assertThat(fn.getName()).isEqualTo("text.jaroWinklerDistance"); + + // Identical strings should return 1.0 + assertThat((Double) fn.execute(new Object[]{"hello", "hello"}, null)).isCloseTo(1.0, within(0.001)); + + // Completely different strings should return 0.0 + assertThat((Double) fn.execute(new Object[]{"abc", "xyz"}, null)).isCloseTo(0.0, within(0.001)); + } + + @Test + void textJaroWinklerDistanceNullHandling() { + final TextJaroWinklerDistance fn = new TextJaroWinklerDistance(); + + assertThat(fn.execute(new Object[]{null, "test"}, null)).isNull(); + assertThat(fn.execute(new Object[]{"test", null}, null)).isNull(); + } + + // ============ TextCamelCase tests ============ + + @Test + void textCamelCaseBasic() { + final TextCamelCase fn = new TextCamelCase(); + assertThat(fn.getName()).isEqualTo("text.camelCase"); + + assertThat(fn.execute(new Object[]{"hello world"}, null)).isEqualTo("helloWorld"); + assertThat(fn.execute(new Object[]{"HELLO_WORLD"}, null)).isEqualTo("helloWorld"); + assertThat(fn.execute(new Object[]{"hello-world"}, null)).isEqualTo("helloWorld"); + } + + @Test + void textCamelCaseEdgeCases() { + final TextCamelCase fn = new TextCamelCase(); + + assertThat(fn.execute(new Object[]{null}, null)).isNull(); + assertThat(fn.execute(new Object[]{""}, null)).isEqualTo(""); + assertThat(fn.execute(new Object[]{"a"}, null)).isEqualTo("a"); + } + + // ============ TextFormat tests ============ + + @Test + void textFormatBasic() { + final TextFormat fn = new TextFormat(); + assertThat(fn.getName()).isEqualTo("text.format"); + + // TextFormat takes varargs, not a list + assertThat(fn.execute(new Object[]{"Hello %s!", "World"}, null)).isEqualTo("Hello World!"); + assertThat(fn.execute(new Object[]{"Value: %d", 42}, null)).isEqualTo("Value: 42"); + } + + @Test + void textFormatNullFormat() { + final TextFormat fn = new TextFormat(); + assertThat(fn.execute(new Object[]{null, "test"}, null)).isNull(); + } + + // ============ TextByteCount tests ============ + + @Test + void textByteCountBasic() { + final TextByteCount fn = new TextByteCount(); + assertThat(fn.getName()).isEqualTo("text.byteCount"); + + assertThat(fn.execute(new Object[]{"hello"}, null)).isEqualTo(5L); + assertThat(fn.execute(new Object[]{""}, null)).isEqualTo(0L); + } + + @Test + void textByteCountUnicode() { + final TextByteCount fn = new TextByteCount(); + + // Multi-byte UTF-8 character + assertThat((Long) fn.execute(new Object[]{"\u00e9"}, null)).isGreaterThan(1L); // é + } + + @Test + void textByteCountNull() { + final TextByteCount fn = new TextByteCount(); + assertThat(fn.execute(new Object[]{null}, null)).isNull(); + } + + // ============ TextCharAt tests ============ + + @Test + void textCharAtBasic() { + final TextCharAt fn = new TextCharAt(); + assertThat(fn.getName()).isEqualTo("text.charAt"); + + assertThat(fn.execute(new Object[]{"hello", 0}, null)).isEqualTo("h"); + assertThat(fn.execute(new Object[]{"hello", 4}, null)).isEqualTo("o"); + } + + @Test + void textCharAtNull() { + final TextCharAt fn = new TextCharAt(); + assertThat(fn.execute(new Object[]{null, 0}, null)).isNull(); + } + + // ============ TextCode tests ============ + + @Test + void textCodeBasic() { + final TextCode fn = new TextCode(); + assertThat(fn.getName()).isEqualTo("text.code"); + + assertThat(fn.execute(new Object[]{"A"}, null)).isEqualTo(65L); + assertThat(fn.execute(new Object[]{"a"}, null)).isEqualTo(97L); + } + + @Test + void textCodeNull() { + final TextCode fn = new TextCode(); + assertThat(fn.execute(new Object[]{null}, null)).isNull(); + } + + // ============ TextHexValue tests ============ + + @Test + void textHexValueFromNumber() { + final TextHexValue fn = new TextHexValue(); + assertThat(fn.getName()).isEqualTo("text.hexValue"); + + // TextHexValue converts numbers to hex string + assertThat(fn.execute(new Object[]{255}, null)).isEqualTo("ff"); + assertThat(fn.execute(new Object[]{16}, null)).isEqualTo("10"); + assertThat(fn.execute(new Object[]{0}, null)).isEqualTo("0"); + } + + @Test + void textHexValueFromString() { + final TextHexValue fn = new TextHexValue(); + + // For strings, each character is converted to 4-digit hex (UTF-16) + assertThat(fn.execute(new Object[]{"A"}, null)).isEqualTo("0041"); + assertThat(fn.execute(new Object[]{"AB"}, null)).isEqualTo("00410042"); + } + + @Test + void textHexValueNull() { + final TextHexValue fn = new TextHexValue(); + assertThat(fn.execute(new Object[]{null}, null)).isNull(); + } + + // ============ TextHammingDistance tests ============ + + @Test + void textHammingDistanceBasic() { + final TextHammingDistance fn = new TextHammingDistance(); + assertThat(fn.getName()).isEqualTo("text.hammingDistance"); + + assertThat(fn.execute(new Object[]{"karolin", "kathrin"}, null)).isEqualTo(3L); + assertThat(fn.execute(new Object[]{"hello", "hello"}, null)).isEqualTo(0L); + } + + @Test + void textHammingDistanceNullHandling() { + final TextHammingDistance fn = new TextHammingDistance(); + + assertThat(fn.execute(new Object[]{null, "test"}, null)).isNull(); + assertThat(fn.execute(new Object[]{"test", null}, null)).isNull(); + } + + // ============ TextDecapitalize tests ============ + + @Test + void textDecapitalizeBasic() { + final TextDecapitalize fn = new TextDecapitalize(); + assertThat(fn.getName()).isEqualTo("text.decapitalize"); + + assertThat(fn.execute(new Object[]{"Hello"}, null)).isEqualTo("hello"); + assertThat(fn.execute(new Object[]{"HELLO"}, null)).isEqualTo("hELLO"); + } + + @Test + void textDecapitalizeEdgeCases() { + final TextDecapitalize fn = new TextDecapitalize(); + + assertThat(fn.execute(new Object[]{null}, null)).isNull(); + assertThat(fn.execute(new Object[]{""}, null)).isEqualTo(""); + } + + // ============ TextCapitalizeAll tests ============ + + @Test + void textCapitalizeAllBasic() { + final TextCapitalizeAll fn = new TextCapitalizeAll(); + assertThat(fn.getName()).isEqualTo("text.capitalizeAll"); + + assertThat(fn.execute(new Object[]{"hello world"}, null)).isEqualTo("Hello World"); + } + + @Test + void textCapitalizeAllNull() { + final TextCapitalizeAll fn = new TextCapitalizeAll(); + assertThat(fn.execute(new Object[]{null}, null)).isNull(); + } + + // ============ TextDecapitalizeAll tests ============ + + @Test + void textDecapitalizeAllBasic() { + final TextDecapitalizeAll fn = new TextDecapitalizeAll(); + assertThat(fn.getName()).isEqualTo("text.decapitalizeAll"); + + assertThat(fn.execute(new Object[]{"Hello World"}, null)).isEqualTo("hello world"); + } + + @Test + void textDecapitalizeAllNull() { + final TextDecapitalizeAll fn = new TextDecapitalizeAll(); + assertThat(fn.execute(new Object[]{null}, null)).isNull(); + } + + // ============ TextRandom tests ============ + + @Test + void textRandomBasic() { + final TextRandom fn = new TextRandom(); + assertThat(fn.getName()).isEqualTo("text.random"); + + final String result = (String) fn.execute(new Object[]{10}, null); + assertThat(result).hasSize(10); + } + + @Test + void textRandomWithCharset() { + final TextRandom fn = new TextRandom(); + + final String result = (String) fn.execute(new Object[]{5, "abc"}, null); + assertThat(result).hasSize(5); + assertThat(result).matches("[abc]+"); + } + + // ============ TextLpad tests ============ + + @Test + void textLpadBasic() { + final TextLpad fn = new TextLpad(); + assertThat(fn.getName()).isEqualTo("text.lpad"); + + assertThat(fn.execute(new Object[]{"test", 8, "0"}, null)).isEqualTo("0000test"); + assertThat(fn.execute(new Object[]{"test", 4, "0"}, null)).isEqualTo("test"); + } + + @Test + void textLpadNull() { + final TextLpad fn = new TextLpad(); + assertThat(fn.execute(new Object[]{null, 8, "0"}, null)).isNull(); + } + + // ============ Function metadata tests ============ + + @Test + void textFunctionMetadata() { + final TextIndexOf fn = new TextIndexOf(); + + assertThat(fn.getMinArgs()).isEqualTo(2); + assertThat(fn.getMaxArgs()).isEqualTo(3); + assertThat(fn.getDescription()).isNotEmpty(); + } + + @Test + void textLevenshteinDistanceMetadata() { + final TextLevenshteinDistance fn = new TextLevenshteinDistance(); + + assertThat(fn.getMinArgs()).isEqualTo(2); + assertThat(fn.getMaxArgs()).isEqualTo(2); + assertThat(fn.getDescription()).contains("Levenshtein"); + } +} diff --git a/engine/src/test/java/com/arcadedb/query/opencypher/functions/OpenCypherUtilFunctionsTest.java b/engine/src/test/java/com/arcadedb/query/opencypher/functions/OpenCypherUtilFunctionsTest.java new file mode 100644 index 0000000000..70127b2f22 --- /dev/null +++ b/engine/src/test/java/com/arcadedb/query/opencypher/functions/OpenCypherUtilFunctionsTest.java @@ -0,0 +1,192 @@ +/* + * Copyright 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.query.opencypher.functions; + +import com.arcadedb.query.opencypher.functions.util.*; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for OpenCypher utility functions. + */ +class OpenCypherUtilFunctionsTest { + + // ============ UtilMd5 tests ============ + + @Test + void utilMd5Basic() { + final UtilMd5 fn = new UtilMd5(); + assertThat(fn.getName()).isEqualTo("util.md5"); + + // Known MD5 hash of "hello" + final String result = (String) fn.execute(new Object[]{"hello"}, null); + assertThat(result).isEqualTo("5d41402abc4b2a76b9719d911017c592"); + } + + @Test + void utilMd5EmptyString() { + final UtilMd5 fn = new UtilMd5(); + + // Known MD5 hash of empty string + final String result = (String) fn.execute(new Object[]{""}, null); + assertThat(result).isEqualTo("d41d8cd98f00b204e9800998ecf8427e"); + } + + @Test + void utilMd5NullHandling() { + final UtilMd5 fn = new UtilMd5(); + assertThat(fn.execute(new Object[]{null}, null)).isNull(); + } + + // ============ UtilSha1 tests ============ + + @Test + void utilSha1Basic() { + final UtilSha1 fn = new UtilSha1(); + assertThat(fn.getName()).isEqualTo("util.sha1"); + + // Known SHA1 hash of "hello" + final String result = (String) fn.execute(new Object[]{"hello"}, null); + assertThat(result).isEqualTo("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d"); + } + + @Test + void utilSha1NullHandling() { + final UtilSha1 fn = new UtilSha1(); + assertThat(fn.execute(new Object[]{null}, null)).isNull(); + } + + // ============ UtilSha256 tests ============ + + @Test + void utilSha256Basic() { + final UtilSha256 fn = new UtilSha256(); + assertThat(fn.getName()).isEqualTo("util.sha256"); + + // Known SHA256 hash of "hello" + final String result = (String) fn.execute(new Object[]{"hello"}, null); + assertThat(result).isEqualTo("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"); + } + + @Test + void utilSha256NullHandling() { + final UtilSha256 fn = new UtilSha256(); + assertThat(fn.execute(new Object[]{null}, null)).isNull(); + } + + // ============ UtilSha512 tests ============ + + @Test + void utilSha512Basic() { + final UtilSha512 fn = new UtilSha512(); + assertThat(fn.getName()).isEqualTo("util.sha512"); + + // Known SHA512 hash of "hello" + final String result = (String) fn.execute(new Object[]{"hello"}, null); + assertThat(result).isEqualTo("9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca72323c3d99ba5c11d7c7acc6e14b8c5da0c4663475c2e5c3adef46f73bcdec043"); + } + + @Test + void utilSha512NullHandling() { + final UtilSha512 fn = new UtilSha512(); + assertThat(fn.execute(new Object[]{null}, null)).isNull(); + } + + // ============ Hash consistency tests ============ + + @Test + void hashFunctionsAreConsistent() { + final UtilMd5 md5 = new UtilMd5(); + final UtilSha1 sha1 = new UtilSha1(); + final UtilSha256 sha256 = new UtilSha256(); + + final String input = "test input"; + + // Same input should always produce same output + assertThat(md5.execute(new Object[]{input}, null)) + .isEqualTo(md5.execute(new Object[]{input}, null)); + assertThat(sha1.execute(new Object[]{input}, null)) + .isEqualTo(sha1.execute(new Object[]{input}, null)); + assertThat(sha256.execute(new Object[]{input}, null)) + .isEqualTo(sha256.execute(new Object[]{input}, null)); + } + + @Test + void hashFunctionsProduceDifferentOutputs() { + final UtilMd5 md5 = new UtilMd5(); + final UtilSha1 sha1 = new UtilSha1(); + final UtilSha256 sha256 = new UtilSha256(); + + final String input = "hello"; + + final String md5Result = (String) md5.execute(new Object[]{input}, null); + final String sha1Result = (String) sha1.execute(new Object[]{input}, null); + final String sha256Result = (String) sha256.execute(new Object[]{input}, null); + + // Different algorithms should produce different hashes + assertThat(md5Result).isNotEqualTo(sha1Result); + assertThat(sha1Result).isNotEqualTo(sha256Result); + assertThat(md5Result).isNotEqualTo(sha256Result); + } + + @Test + void hashLengths() { + final UtilMd5 md5 = new UtilMd5(); + final UtilSha1 sha1 = new UtilSha1(); + final UtilSha256 sha256 = new UtilSha256(); + final UtilSha512 sha512 = new UtilSha512(); + + final String input = "test"; + + // Check expected hash lengths (in hex characters) + assertThat(((String) md5.execute(new Object[]{input}, null)).length()).isEqualTo(32); // 128 bits + assertThat(((String) sha1.execute(new Object[]{input}, null)).length()).isEqualTo(40); // 160 bits + assertThat(((String) sha256.execute(new Object[]{input}, null)).length()).isEqualTo(64); // 256 bits + assertThat(((String) sha512.execute(new Object[]{input}, null)).length()).isEqualTo(128); // 512 bits + } + + // ============ Metadata tests ============ + + @Test + void utilFunctionsMetadata() { + final UtilMd5 md5 = new UtilMd5(); + assertThat(md5.getMinArgs()).isEqualTo(1); + assertThat(md5.getMaxArgs()).isEqualTo(1); + assertThat(md5.getDescription()).contains("MD5"); + + final UtilSha256 sha256 = new UtilSha256(); + assertThat(sha256.getMinArgs()).isEqualTo(1); + assertThat(sha256.getMaxArgs()).isEqualTo(1); + assertThat(sha256.getDescription()).contains("SHA-256"); + } + + // ============ Non-string input tests ============ + + @Test + void hashFunctionsConvertNonStringsToString() { + final UtilMd5 md5 = new UtilMd5(); + + // Numbers should be converted to string before hashing + final String hashOfInt = (String) md5.execute(new Object[]{42}, null); + final String hashOfString = (String) md5.execute(new Object[]{"42"}, null); + + assertThat(hashOfInt).isEqualTo(hashOfString); + } +} diff --git a/server/src/main/java/com/arcadedb/server/http/handler/ExecutionResponse.java b/server/src/main/java/com/arcadedb/server/http/handler/ExecutionResponse.java index c9a1bd2965..17a22d9f4d 100644 --- a/server/src/main/java/com/arcadedb/server/http/handler/ExecutionResponse.java +++ b/server/src/main/java/com/arcadedb/server/http/handler/ExecutionResponse.java @@ -40,6 +40,22 @@ public ExecutionResponse(final int code, final byte[] bytes) { this.binary = bytes; } + public int getCode() { + return code; + } + + public String getResponse() { + return response; + } + + public byte[] getBinary() { + return binary; + } + + public boolean isBinary() { + return binary != null; + } + public void send(final HttpServerExchange exchange) { exchange.setStatusCode(code); if (binary != null) { diff --git a/server/src/test/java/com/arcadedb/server/backup/BackupSchedulerTest.java b/server/src/test/java/com/arcadedb/server/backup/BackupSchedulerTest.java new file mode 100644 index 0000000000..26dcec184e --- /dev/null +++ b/server/src/test/java/com/arcadedb/server/backup/BackupSchedulerTest.java @@ -0,0 +1,248 @@ +/* + * Copyright 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.server.backup; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for BackupScheduler lifecycle and scheduling logic. + */ +class BackupSchedulerTest { + + @TempDir + Path tempDir; + + private BackupScheduler scheduler; + + @BeforeEach + void setUp() { + // Create scheduler with null server (won't execute backups, just tests scheduling) + scheduler = new BackupScheduler(null, tempDir.toString(), null); + } + + @AfterEach + void tearDown() { + if (scheduler != null && scheduler.isRunning()) + scheduler.stop(); + } + + @Test + void initialStateNotRunning() { + assertThat(scheduler.isRunning()).isFalse(); + assertThat(scheduler.getScheduledCount()).isEqualTo(0); + } + + @Test + void startMakesSchedulerRunning() { + scheduler.start(); + assertThat(scheduler.isRunning()).isTrue(); + } + + @Test + void stopMakesSchedulerNotRunning() { + scheduler.start(); + assertThat(scheduler.isRunning()).isTrue(); + + scheduler.stop(); + assertThat(scheduler.isRunning()).isFalse(); + } + + @Test + void stopClearsScheduledTasks() { + scheduler.start(); + + // Schedule a backup + final DatabaseBackupConfig config = createFrequencyConfig("testdb", 60); + scheduler.scheduleBackup("testdb", config); + assertThat(scheduler.getScheduledCount()).isEqualTo(1); + + scheduler.stop(); + assertThat(scheduler.getScheduledCount()).isEqualTo(0); + } + + @Test + void scheduleBackupWhenNotRunningDoesNotSchedule() { + // Scheduler not started + final DatabaseBackupConfig config = createFrequencyConfig("testdb", 60); + scheduler.scheduleBackup("testdb", config); + + assertThat(scheduler.getScheduledCount()).isEqualTo(0); + } + + @Test + void scheduleFrequencyBasedBackup() { + scheduler.start(); + + final DatabaseBackupConfig config = createFrequencyConfig("testdb", 60); + scheduler.scheduleBackup("testdb", config); + + assertThat(scheduler.getScheduledCount()).isEqualTo(1); + } + + @Test + void scheduleCronBasedBackup() { + scheduler.start(); + + // Schedule daily at 2 AM + final DatabaseBackupConfig config = createCronConfig("testdb", "0 0 2 * * *"); + scheduler.scheduleBackup("testdb", config); + + assertThat(scheduler.getScheduledCount()).isEqualTo(1); + } + + @Test + void scheduleMultipleDatabases() { + scheduler.start(); + + scheduler.scheduleBackup("db1", createFrequencyConfig("db1", 30)); + scheduler.scheduleBackup("db2", createFrequencyConfig("db2", 60)); + scheduler.scheduleBackup("db3", createCronConfig("db3", "0 0 3 * * *")); + + assertThat(scheduler.getScheduledCount()).isEqualTo(3); + } + + @Test + void cancelBackupRemovesFromSchedule() { + scheduler.start(); + + scheduler.scheduleBackup("testdb", createFrequencyConfig("testdb", 60)); + assertThat(scheduler.getScheduledCount()).isEqualTo(1); + + scheduler.cancelBackup("testdb"); + assertThat(scheduler.getScheduledCount()).isEqualTo(0); + } + + @Test + void cancelNonExistentBackupDoesNothing() { + scheduler.start(); + + scheduler.scheduleBackup("testdb", createFrequencyConfig("testdb", 60)); + assertThat(scheduler.getScheduledCount()).isEqualTo(1); + + // Cancel a backup that doesn't exist + scheduler.cancelBackup("nonexistent"); + + // Original backup should still be scheduled + assertThat(scheduler.getScheduledCount()).isEqualTo(1); + } + + @Test + void reschedulingReplacesExistingSchedule() { + scheduler.start(); + + // Schedule initial backup + scheduler.scheduleBackup("testdb", createFrequencyConfig("testdb", 30)); + assertThat(scheduler.getScheduledCount()).isEqualTo(1); + + // Reschedule with different frequency - should replace, not add + scheduler.scheduleBackup("testdb", createFrequencyConfig("testdb", 60)); + assertThat(scheduler.getScheduledCount()).isEqualTo(1); + } + + @Test + void disabledConfigDoesNotSchedule() { + scheduler.start(); + + final DatabaseBackupConfig config = createFrequencyConfig("testdb", 60); + config.setEnabled(false); + scheduler.scheduleBackup("testdb", config); + + assertThat(scheduler.getScheduledCount()).isEqualTo(0); + } + + @Test + void nullScheduleConfigDoesNotSchedule() { + scheduler.start(); + + final DatabaseBackupConfig config = new DatabaseBackupConfig("testdb"); + // No schedule set - schedule is null + scheduler.scheduleBackup("testdb", config); + + assertThat(scheduler.getScheduledCount()).isEqualTo(0); + } + + @Test + void triggerImmediateBackupWhenNotRunningDoesNothing() { + // Scheduler not started + final DatabaseBackupConfig config = createFrequencyConfig("testdb", 60); + + // Should not throw, just return silently + scheduler.triggerImmediateBackup("testdb", config); + + assertThat(scheduler.getScheduledCount()).isEqualTo(0); + } + + @Test + void triggerImmediateBackupWhenRunning() { + scheduler.start(); + + final DatabaseBackupConfig config = createFrequencyConfig("testdb", 60); + + // Should not throw - the task will fail to execute backup since server is null, + // but triggering should work + scheduler.triggerImmediateBackup("testdb", config); + + // Immediate backups don't add to scheduled count - they run once + // The scheduled count stays 0 unless we explicitly schedule + assertThat(scheduler.getScheduledCount()).isEqualTo(0); + } + + @Test + void invalidCronExpressionDoesNotSchedule() { + scheduler.start(); + + // Invalid CRON expression (only 5 fields instead of 6) + final DatabaseBackupConfig config = createCronConfig("testdb", "0 0 2 * *"); + scheduler.scheduleBackup("testdb", config); + + // Should not schedule due to invalid CRON + assertThat(scheduler.getScheduledCount()).isEqualTo(0); + } + + // Helper methods to create backup configurations + + private DatabaseBackupConfig createFrequencyConfig(final String databaseName, final int frequencyMinutes) { + final DatabaseBackupConfig config = new DatabaseBackupConfig(databaseName); + + final DatabaseBackupConfig.ScheduleConfig schedule = new DatabaseBackupConfig.ScheduleConfig(); + schedule.setType(DatabaseBackupConfig.ScheduleConfig.Type.FREQUENCY); + schedule.setFrequencyMinutes(frequencyMinutes); + config.setSchedule(schedule); + + return config; + } + + private DatabaseBackupConfig createCronConfig(final String databaseName, final String cronExpression) { + final DatabaseBackupConfig config = new DatabaseBackupConfig(databaseName); + + final DatabaseBackupConfig.ScheduleConfig schedule = new DatabaseBackupConfig.ScheduleConfig(); + schedule.setType(DatabaseBackupConfig.ScheduleConfig.Type.CRON); + schedule.setCronExpression(cronExpression); + config.setSchedule(schedule); + + return config; + } +} diff --git a/server/src/test/java/com/arcadedb/server/backup/DatabaseBackupConfigTest.java b/server/src/test/java/com/arcadedb/server/backup/DatabaseBackupConfigTest.java new file mode 100644 index 0000000000..535a79bd4d --- /dev/null +++ b/server/src/test/java/com/arcadedb/server/backup/DatabaseBackupConfigTest.java @@ -0,0 +1,753 @@ +/* + * Copyright 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.server.backup; + +import com.arcadedb.serializer.json.JSONObject; +import org.junit.jupiter.api.Test; + +import java.time.LocalTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for DatabaseBackupConfig and its nested configuration classes. + */ +class DatabaseBackupConfigTest { + + // ============ DatabaseBackupConfig tests ============ + + @Test + void createWithDatabaseName() { + final DatabaseBackupConfig config = new DatabaseBackupConfig("testdb"); + + assertThat(config.getDatabaseName()).isEqualTo("testdb"); + assertThat(config.isEnabled()).isTrue(); // Default + assertThat(config.getRunOnServer()).isEqualTo("$leader"); // Default + } + + @Test + void fromJsonBasic() { + final JSONObject json = new JSONObject() + .put("enabled", false) + .put("runOnServer", "server1"); + + final DatabaseBackupConfig config = DatabaseBackupConfig.fromJSON("mydb", json); + + assertThat(config.getDatabaseName()).isEqualTo("mydb"); + assertThat(config.isEnabled()).isFalse(); + assertThat(config.getRunOnServer()).isEqualTo("server1"); + } + + @Test + void fromJsonWithSchedule() { + final JSONObject scheduleJson = new JSONObject() + .put("type", "FREQUENCY") + .put("frequencyMinutes", 30); + + final JSONObject json = new JSONObject() + .put("schedule", scheduleJson); + + final DatabaseBackupConfig config = DatabaseBackupConfig.fromJSON("mydb", json); + + assertThat(config.getSchedule()).isNotNull(); + assertThat(config.getSchedule().getType()).isEqualTo(DatabaseBackupConfig.ScheduleConfig.Type.FREQUENCY); + assertThat(config.getSchedule().getFrequencyMinutes()).isEqualTo(30); + } + + @Test + void fromJsonWithRetention() { + final JSONObject retentionJson = new JSONObject() + .put("maxFiles", 20); + + final JSONObject json = new JSONObject() + .put("retention", retentionJson); + + final DatabaseBackupConfig config = DatabaseBackupConfig.fromJSON("mydb", json); + + assertThat(config.getRetention()).isNotNull(); + assertThat(config.getRetention().getMaxFiles()).isEqualTo(20); + } + + @Test + void toJsonRoundTrip() { + final DatabaseBackupConfig config = new DatabaseBackupConfig("testdb"); + config.setEnabled(false); + config.setRunOnServer("server2"); + + final DatabaseBackupConfig.ScheduleConfig schedule = new DatabaseBackupConfig.ScheduleConfig(); + schedule.setType(DatabaseBackupConfig.ScheduleConfig.Type.FREQUENCY); + schedule.setFrequencyMinutes(45); + config.setSchedule(schedule); + + final JSONObject json = config.toJSON(); + + assertThat(json.getBoolean("enabled")).isFalse(); + assertThat(json.getString("runOnServer")).isEqualTo("server2"); + assertThat(json.has("schedule")).isTrue(); + } + + @Test + void mergeWithDefaultsSchedule() { + final DatabaseBackupConfig defaults = new DatabaseBackupConfig("default"); + final DatabaseBackupConfig.ScheduleConfig defaultSchedule = new DatabaseBackupConfig.ScheduleConfig(); + defaultSchedule.setFrequencyMinutes(60); + defaults.setSchedule(defaultSchedule); + + final DatabaseBackupConfig config = new DatabaseBackupConfig("testdb"); + config.mergeWithDefaults(defaults); + + assertThat(config.getSchedule()).isNotNull(); + assertThat(config.getSchedule().getFrequencyMinutes()).isEqualTo(60); + } + + @Test + void mergeWithDefaultsRetention() { + final DatabaseBackupConfig defaults = new DatabaseBackupConfig("default"); + final DatabaseBackupConfig.RetentionConfig defaultRetention = new DatabaseBackupConfig.RetentionConfig(); + defaultRetention.setMaxFiles(15); + defaults.setRetention(defaultRetention); + + final DatabaseBackupConfig config = new DatabaseBackupConfig("testdb"); + config.mergeWithDefaults(defaults); + + assertThat(config.getRetention()).isNotNull(); + assertThat(config.getRetention().getMaxFiles()).isEqualTo(15); + } + + @Test + void mergeWithNullDefaults() { + final DatabaseBackupConfig config = new DatabaseBackupConfig("testdb"); + config.mergeWithDefaults(null); + + // Should not throw, schedule and retention remain null + assertThat(config.getSchedule()).isNull(); + assertThat(config.getRetention()).isNull(); + } + + @Test + void mergeScheduleWithExistingConfig() { + // When config has schedule, merge window settings from defaults + final DatabaseBackupConfig defaults = new DatabaseBackupConfig("default"); + final DatabaseBackupConfig.ScheduleConfig defaultSchedule = new DatabaseBackupConfig.ScheduleConfig(); + defaultSchedule.setWindowStart(LocalTime.of(2, 0)); + defaultSchedule.setWindowEnd(LocalTime.of(4, 0)); + defaults.setSchedule(defaultSchedule); + + final DatabaseBackupConfig config = new DatabaseBackupConfig("testdb"); + final DatabaseBackupConfig.ScheduleConfig configSchedule = new DatabaseBackupConfig.ScheduleConfig(); + configSchedule.setFrequencyMinutes(30); + config.setSchedule(configSchedule); + + config.mergeWithDefaults(defaults); + + assertThat(config.getSchedule().getFrequencyMinutes()).isEqualTo(30); // Own value preserved + assertThat(config.getSchedule().getWindowStart()).isEqualTo(LocalTime.of(2, 0)); // Merged from defaults + assertThat(config.getSchedule().getWindowEnd()).isEqualTo(LocalTime.of(4, 0)); // Merged from defaults + } + + @Test + void mergeRetentionWithExistingConfig() { + // When config has retention, merge tiered settings from defaults + final DatabaseBackupConfig defaults = new DatabaseBackupConfig("default"); + final DatabaseBackupConfig.RetentionConfig defaultRetention = new DatabaseBackupConfig.RetentionConfig(); + final DatabaseBackupConfig.TieredConfig tiered = new DatabaseBackupConfig.TieredConfig(); + tiered.setHourly(48); + defaultRetention.setTiered(tiered); + defaults.setRetention(defaultRetention); + + final DatabaseBackupConfig config = new DatabaseBackupConfig("testdb"); + final DatabaseBackupConfig.RetentionConfig configRetention = new DatabaseBackupConfig.RetentionConfig(); + configRetention.setMaxFiles(25); + config.setRetention(configRetention); + + config.mergeWithDefaults(defaults); + + assertThat(config.getRetention().getMaxFiles()).isEqualTo(25); // Own value preserved + assertThat(config.getRetention().getTiered()).isNotNull(); // Merged from defaults + assertThat(config.getRetention().getTiered().getHourly()).isEqualTo(48); + } + + @Test + void validateCallsScheduleValidate() { + final DatabaseBackupConfig config = new DatabaseBackupConfig("testdb"); + final DatabaseBackupConfig.ScheduleConfig schedule = new DatabaseBackupConfig.ScheduleConfig(); + schedule.setType(DatabaseBackupConfig.ScheduleConfig.Type.FREQUENCY); + schedule.setFrequencyMinutes(0); // Invalid + config.setSchedule(schedule); + + assertThatThrownBy(config::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("at least 1 minute"); + } + + @Test + void validateCallsRetentionValidate() { + final DatabaseBackupConfig config = new DatabaseBackupConfig("testdb"); + final DatabaseBackupConfig.RetentionConfig retention = new DatabaseBackupConfig.RetentionConfig(); + retention.setMaxFiles(0); // Invalid + config.setRetention(retention); + + assertThatThrownBy(config::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("at least 1"); + } + + // ============ ScheduleConfig tests ============ + + @Test + void scheduleConfigFromJsonFrequency() { + final JSONObject json = new JSONObject() + .put("type", "frequency") + .put("frequencyMinutes", 120); + + final DatabaseBackupConfig.ScheduleConfig config = DatabaseBackupConfig.ScheduleConfig.fromJSON(json); + + assertThat(config.getType()).isEqualTo(DatabaseBackupConfig.ScheduleConfig.Type.FREQUENCY); + assertThat(config.getFrequencyMinutes()).isEqualTo(120); + } + + @Test + void scheduleConfigFromJsonCron() { + final JSONObject json = new JSONObject() + .put("type", "CRON") + .put("expression", "0 0 2 * * *"); + + final DatabaseBackupConfig.ScheduleConfig config = DatabaseBackupConfig.ScheduleConfig.fromJSON(json); + + assertThat(config.getType()).isEqualTo(DatabaseBackupConfig.ScheduleConfig.Type.CRON); + assertThat(config.getCronExpression()).isEqualTo("0 0 2 * * *"); + } + + @Test + void scheduleConfigWithTimeWindow() { + final JSONObject windowJson = new JSONObject() + .put("start", "02:00") + .put("end", "04:00"); + + final JSONObject json = new JSONObject() + .put("timeWindow", windowJson); + + final DatabaseBackupConfig.ScheduleConfig config = DatabaseBackupConfig.ScheduleConfig.fromJSON(json); + + assertThat(config.hasTimeWindow()).isTrue(); + assertThat(config.getWindowStart()).isEqualTo(LocalTime.of(2, 0)); + assertThat(config.getWindowEnd()).isEqualTo(LocalTime.of(4, 0)); + } + + @Test + void scheduleConfigWithPartialTimeWindow() { + // Only start specified + final JSONObject windowJson = new JSONObject() + .put("start", "02:00"); + + final JSONObject json = new JSONObject() + .put("timeWindow", windowJson); + + final DatabaseBackupConfig.ScheduleConfig config = DatabaseBackupConfig.ScheduleConfig.fromJSON(json); + + assertThat(config.hasTimeWindow()).isFalse(); // Needs both start and end + assertThat(config.getWindowStart()).isEqualTo(LocalTime.of(2, 0)); + assertThat(config.getWindowEnd()).isNull(); + } + + @Test + void scheduleConfigValidateFrequencyTooLow() { + final DatabaseBackupConfig.ScheduleConfig config = new DatabaseBackupConfig.ScheduleConfig(); + config.setType(DatabaseBackupConfig.ScheduleConfig.Type.FREQUENCY); + config.setFrequencyMinutes(0); + + assertThatThrownBy(config::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("at least 1 minute"); + } + + @Test + void scheduleConfigValidateFrequencyTooHigh() { + final DatabaseBackupConfig.ScheduleConfig config = new DatabaseBackupConfig.ScheduleConfig(); + config.setType(DatabaseBackupConfig.ScheduleConfig.Type.FREQUENCY); + config.setFrequencyMinutes(600000); // More than 1 year + + assertThatThrownBy(config::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot exceed 1 year"); + } + + @Test + void scheduleConfigValidateFrequencyValid() { + final DatabaseBackupConfig.ScheduleConfig config = new DatabaseBackupConfig.ScheduleConfig(); + config.setType(DatabaseBackupConfig.ScheduleConfig.Type.FREQUENCY); + config.setFrequencyMinutes(60); + + // Should not throw + config.validate(); + } + + @Test + void scheduleConfigValidateCronMissing() { + final DatabaseBackupConfig.ScheduleConfig config = new DatabaseBackupConfig.ScheduleConfig(); + config.setType(DatabaseBackupConfig.ScheduleConfig.Type.CRON); + config.setCronExpression(null); + + assertThatThrownBy(config::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("CRON expression is required"); + } + + @Test + void scheduleConfigValidateCronEmpty() { + final DatabaseBackupConfig.ScheduleConfig config = new DatabaseBackupConfig.ScheduleConfig(); + config.setType(DatabaseBackupConfig.ScheduleConfig.Type.CRON); + config.setCronExpression(" "); + + assertThatThrownBy(config::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("CRON expression is required"); + } + + @Test + void scheduleConfigValidateCronInvalid() { + final DatabaseBackupConfig.ScheduleConfig config = new DatabaseBackupConfig.ScheduleConfig(); + config.setType(DatabaseBackupConfig.ScheduleConfig.Type.CRON); + config.setCronExpression("invalid"); + + assertThatThrownBy(config::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid CRON expression"); + } + + @Test + void scheduleConfigValidateCronValid() { + final DatabaseBackupConfig.ScheduleConfig config = new DatabaseBackupConfig.ScheduleConfig(); + config.setType(DatabaseBackupConfig.ScheduleConfig.Type.CRON); + config.setCronExpression("0 0 2 * * *"); // Every day at 2 AM + + // Should not throw + config.validate(); + } + + @Test + void scheduleConfigToJson() { + final DatabaseBackupConfig.ScheduleConfig config = new DatabaseBackupConfig.ScheduleConfig(); + config.setType(DatabaseBackupConfig.ScheduleConfig.Type.FREQUENCY); + config.setFrequencyMinutes(30); + config.setWindowStart(LocalTime.of(1, 0)); + config.setWindowEnd(LocalTime.of(5, 0)); + + final JSONObject json = config.toJSON(); + + assertThat(json.getString("type")).isEqualTo("frequency"); + assertThat(json.getInt("frequencyMinutes")).isEqualTo(30); + assertThat(json.has("timeWindow")).isTrue(); + } + + @Test + void scheduleConfigToJsonCron() { + final DatabaseBackupConfig.ScheduleConfig config = new DatabaseBackupConfig.ScheduleConfig(); + config.setType(DatabaseBackupConfig.ScheduleConfig.Type.CRON); + config.setCronExpression("0 0 3 * * MON"); + + final JSONObject json = config.toJSON(); + + assertThat(json.getString("type")).isEqualTo("cron"); + assertThat(json.getString("expression")).isEqualTo("0 0 3 * * MON"); + assertThat(json.has("frequencyMinutes")).isFalse(); + } + + @Test + void scheduleConfigMergeTimeWindow() { + final DatabaseBackupConfig.ScheduleConfig defaults = new DatabaseBackupConfig.ScheduleConfig(); + defaults.setWindowStart(LocalTime.of(2, 0)); + defaults.setWindowEnd(LocalTime.of(4, 0)); + + final DatabaseBackupConfig.ScheduleConfig config = new DatabaseBackupConfig.ScheduleConfig(); + config.mergeWithDefaults(defaults); + + assertThat(config.getWindowStart()).isEqualTo(LocalTime.of(2, 0)); + assertThat(config.getWindowEnd()).isEqualTo(LocalTime.of(4, 0)); + } + + @Test + void scheduleConfigMergePreservesExisting() { + final DatabaseBackupConfig.ScheduleConfig defaults = new DatabaseBackupConfig.ScheduleConfig(); + defaults.setWindowStart(LocalTime.of(2, 0)); + defaults.setWindowEnd(LocalTime.of(4, 0)); + + final DatabaseBackupConfig.ScheduleConfig config = new DatabaseBackupConfig.ScheduleConfig(); + config.setWindowStart(LocalTime.of(1, 0)); // Own value + + config.mergeWithDefaults(defaults); + + assertThat(config.getWindowStart()).isEqualTo(LocalTime.of(1, 0)); // Preserved + assertThat(config.getWindowEnd()).isEqualTo(LocalTime.of(4, 0)); // Merged + } + + @Test + void scheduleConfigDefaultType() { + final DatabaseBackupConfig.ScheduleConfig config = new DatabaseBackupConfig.ScheduleConfig(); + + assertThat(config.getType()).isEqualTo(DatabaseBackupConfig.ScheduleConfig.Type.FREQUENCY); + assertThat(config.getFrequencyMinutes()).isEqualTo(60); // Default + } + + // ============ RetentionConfig tests ============ + + @Test + void retentionConfigFromJson() { + final JSONObject json = new JSONObject() + .put("maxFiles", 25); + + final DatabaseBackupConfig.RetentionConfig config = DatabaseBackupConfig.RetentionConfig.fromJSON(json); + + assertThat(config.getMaxFiles()).isEqualTo(25); + } + + @Test + void retentionConfigWithTiered() { + final JSONObject tieredJson = new JSONObject() + .put("hourly", 12) + .put("daily", 5) + .put("weekly", 2) + .put("monthly", 6) + .put("yearly", 1); + + final JSONObject json = new JSONObject() + .put("tiered", tieredJson); + + final DatabaseBackupConfig.RetentionConfig config = DatabaseBackupConfig.RetentionConfig.fromJSON(json); + + assertThat(config.hasTieredRetention()).isTrue(); + assertThat(config.getTiered().getHourly()).isEqualTo(12); + assertThat(config.getTiered().getDaily()).isEqualTo(5); + assertThat(config.getTiered().getWeekly()).isEqualTo(2); + assertThat(config.getTiered().getMonthly()).isEqualTo(6); + assertThat(config.getTiered().getYearly()).isEqualTo(1); + } + + @Test + void retentionConfigValidateMaxFilesTooLow() { + final DatabaseBackupConfig.RetentionConfig config = new DatabaseBackupConfig.RetentionConfig(); + config.setMaxFiles(0); + + assertThatThrownBy(config::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("at least 1"); + } + + @Test + void retentionConfigValidateMaxFilesTooHigh() { + final DatabaseBackupConfig.RetentionConfig config = new DatabaseBackupConfig.RetentionConfig(); + config.setMaxFiles(20000); + + assertThatThrownBy(config::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot exceed 10000"); + } + + @Test + void retentionConfigValidateMaxFilesValid() { + final DatabaseBackupConfig.RetentionConfig config = new DatabaseBackupConfig.RetentionConfig(); + config.setMaxFiles(100); + + // Should not throw + config.validate(); + } + + @Test + void retentionConfigValidatesTiered() { + final DatabaseBackupConfig.RetentionConfig config = new DatabaseBackupConfig.RetentionConfig(); + final DatabaseBackupConfig.TieredConfig tiered = new DatabaseBackupConfig.TieredConfig(); + tiered.setHourly(-1); + config.setTiered(tiered); + + assertThatThrownBy(config::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot be negative"); + } + + @Test + void retentionConfigToJson() { + final DatabaseBackupConfig.RetentionConfig config = new DatabaseBackupConfig.RetentionConfig(); + config.setMaxFiles(50); + + final JSONObject json = config.toJSON(); + + assertThat(json.getInt("maxFiles")).isEqualTo(50); + } + + @Test + void retentionConfigToJsonWithTiered() { + final DatabaseBackupConfig.RetentionConfig config = new DatabaseBackupConfig.RetentionConfig(); + config.setMaxFiles(50); + final DatabaseBackupConfig.TieredConfig tiered = new DatabaseBackupConfig.TieredConfig(); + tiered.setDaily(14); + config.setTiered(tiered); + + final JSONObject json = config.toJSON(); + + assertThat(json.getInt("maxFiles")).isEqualTo(50); + assertThat(json.has("tiered")).isTrue(); + assertThat(json.getJSONObject("tiered").getInt("daily")).isEqualTo(14); + } + + @Test + void retentionConfigMergeWithDefaults() { + final DatabaseBackupConfig.RetentionConfig defaults = new DatabaseBackupConfig.RetentionConfig(); + final DatabaseBackupConfig.TieredConfig tiered = new DatabaseBackupConfig.TieredConfig(); + tiered.setHourly(48); + defaults.setTiered(tiered); + + final DatabaseBackupConfig.RetentionConfig config = new DatabaseBackupConfig.RetentionConfig(); + config.mergeWithDefaults(defaults); + + assertThat(config.getTiered()).isNotNull(); + assertThat(config.getTiered().getHourly()).isEqualTo(48); + } + + @Test + void retentionConfigMergePreservesExisting() { + final DatabaseBackupConfig.RetentionConfig defaults = new DatabaseBackupConfig.RetentionConfig(); + final DatabaseBackupConfig.TieredConfig tiered = new DatabaseBackupConfig.TieredConfig(); + defaults.setTiered(tiered); + + final DatabaseBackupConfig.RetentionConfig config = new DatabaseBackupConfig.RetentionConfig(); + final DatabaseBackupConfig.TieredConfig ownTiered = new DatabaseBackupConfig.TieredConfig(); + ownTiered.setHourly(100); + config.setTiered(ownTiered); + + config.mergeWithDefaults(defaults); + + assertThat(config.getTiered().getHourly()).isEqualTo(100); // Preserved + } + + @Test + void retentionConfigDefaultMaxFiles() { + final DatabaseBackupConfig.RetentionConfig config = new DatabaseBackupConfig.RetentionConfig(); + + assertThat(config.getMaxFiles()).isEqualTo(10); // Default + } + + // ============ TieredConfig tests ============ + + @Test + void tieredConfigFromJson() { + final JSONObject json = new JSONObject() + .put("hourly", 48) + .put("daily", 14) + .put("weekly", 8) + .put("monthly", 24) + .put("yearly", 5); + + final DatabaseBackupConfig.TieredConfig config = DatabaseBackupConfig.TieredConfig.fromJSON(json); + + assertThat(config.getHourly()).isEqualTo(48); + assertThat(config.getDaily()).isEqualTo(14); + assertThat(config.getWeekly()).isEqualTo(8); + assertThat(config.getMonthly()).isEqualTo(24); + assertThat(config.getYearly()).isEqualTo(5); + } + + @Test + void tieredConfigDefaults() { + final DatabaseBackupConfig.TieredConfig config = new DatabaseBackupConfig.TieredConfig(); + + assertThat(config.getHourly()).isEqualTo(24); + assertThat(config.getDaily()).isEqualTo(7); + assertThat(config.getWeekly()).isEqualTo(4); + assertThat(config.getMonthly()).isEqualTo(12); + assertThat(config.getYearly()).isEqualTo(3); + } + + @Test + void tieredConfigValidateNegativeHourly() { + final DatabaseBackupConfig.TieredConfig config = new DatabaseBackupConfig.TieredConfig(); + config.setHourly(-1); + + assertThatThrownBy(config::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot be negative"); + } + + @Test + void tieredConfigValidateNegativeDaily() { + final DatabaseBackupConfig.TieredConfig config = new DatabaseBackupConfig.TieredConfig(); + config.setDaily(-1); + + assertThatThrownBy(config::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot be negative"); + } + + @Test + void tieredConfigValidateNegativeWeekly() { + final DatabaseBackupConfig.TieredConfig config = new DatabaseBackupConfig.TieredConfig(); + config.setWeekly(-1); + + assertThatThrownBy(config::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot be negative"); + } + + @Test + void tieredConfigValidateNegativeMonthly() { + final DatabaseBackupConfig.TieredConfig config = new DatabaseBackupConfig.TieredConfig(); + config.setMonthly(-1); + + assertThatThrownBy(config::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot be negative"); + } + + @Test + void tieredConfigValidateNegativeYearly() { + final DatabaseBackupConfig.TieredConfig config = new DatabaseBackupConfig.TieredConfig(); + config.setYearly(-1); + + assertThatThrownBy(config::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot be negative"); + } + + @Test + void tieredConfigValidateTooHighHourly() { + final DatabaseBackupConfig.TieredConfig config = new DatabaseBackupConfig.TieredConfig(); + config.setHourly(2000); + + assertThatThrownBy(config::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot exceed 1000"); + } + + @Test + void tieredConfigValidateTooHighDaily() { + final DatabaseBackupConfig.TieredConfig config = new DatabaseBackupConfig.TieredConfig(); + config.setDaily(2000); + + assertThatThrownBy(config::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot exceed 1000"); + } + + @Test + void tieredConfigValidateValid() { + final DatabaseBackupConfig.TieredConfig config = new DatabaseBackupConfig.TieredConfig(); + + // Should not throw with defaults + config.validate(); + } + + @Test + void tieredConfigToJson() { + final DatabaseBackupConfig.TieredConfig config = new DatabaseBackupConfig.TieredConfig(); + config.setHourly(10); + config.setDaily(5); + + final JSONObject json = config.toJSON(); + + assertThat(json.getInt("hourly")).isEqualTo(10); + assertThat(json.getInt("daily")).isEqualTo(5); + assertThat(json.getInt("weekly")).isEqualTo(4); // Default + assertThat(json.getInt("monthly")).isEqualTo(12); // Default + assertThat(json.getInt("yearly")).isEqualTo(3); // Default + } + + // ============ Setters tests ============ + + @Test + void settersWork() { + final DatabaseBackupConfig config = new DatabaseBackupConfig("db"); + config.setEnabled(false); + config.setRunOnServer("myserver"); + + assertThat(config.isEnabled()).isFalse(); + assertThat(config.getRunOnServer()).isEqualTo("myserver"); + } + + @Test + void scheduleSettersWork() { + final DatabaseBackupConfig.ScheduleConfig config = new DatabaseBackupConfig.ScheduleConfig(); + config.setType(DatabaseBackupConfig.ScheduleConfig.Type.CRON); + config.setCronExpression("0 0 * * * *"); + config.setFrequencyMinutes(99); + + assertThat(config.getType()).isEqualTo(DatabaseBackupConfig.ScheduleConfig.Type.CRON); + assertThat(config.getCronExpression()).isEqualTo("0 0 * * * *"); + assertThat(config.getFrequencyMinutes()).isEqualTo(99); + } + + @Test + void retentionSettersWork() { + final DatabaseBackupConfig.RetentionConfig retention = new DatabaseBackupConfig.RetentionConfig(); + final DatabaseBackupConfig.TieredConfig tiered = new DatabaseBackupConfig.TieredConfig(); + retention.setTiered(tiered); + + assertThat(retention.getTiered()).isSameAs(tiered); + } + + @Test + void tieredSettersWork() { + final DatabaseBackupConfig.TieredConfig config = new DatabaseBackupConfig.TieredConfig(); + config.setWeekly(10); + config.setMonthly(20); + config.setYearly(30); + + assertThat(config.getWeekly()).isEqualTo(10); + assertThat(config.getMonthly()).isEqualTo(20); + assertThat(config.getYearly()).isEqualTo(30); + } + + // ============ Additional edge cases ============ + + @Test + void fromJsonWithEmptyObject() { + final JSONObject json = new JSONObject(); + + final DatabaseBackupConfig config = DatabaseBackupConfig.fromJSON("mydb", json); + + assertThat(config.getDatabaseName()).isEqualTo("mydb"); + assertThat(config.isEnabled()).isTrue(); // Default + assertThat(config.getRunOnServer()).isEqualTo("$leader"); // Default + assertThat(config.getSchedule()).isNull(); + assertThat(config.getRetention()).isNull(); + } + + @Test + void scheduleConfigFromJsonWithDefaults() { + final JSONObject json = new JSONObject(); // Empty + + final DatabaseBackupConfig.ScheduleConfig config = DatabaseBackupConfig.ScheduleConfig.fromJSON(json); + + assertThat(config.getType()).isEqualTo(DatabaseBackupConfig.ScheduleConfig.Type.FREQUENCY); + assertThat(config.getFrequencyMinutes()).isEqualTo(60); + } + + @Test + void hasTieredRetentionFalse() { + final DatabaseBackupConfig.RetentionConfig config = new DatabaseBackupConfig.RetentionConfig(); + + assertThat(config.hasTieredRetention()).isFalse(); + } + + @Test + void hasTimeWindowFalse() { + final DatabaseBackupConfig.ScheduleConfig config = new DatabaseBackupConfig.ScheduleConfig(); + + assertThat(config.hasTimeWindow()).isFalse(); + } +} diff --git a/server/src/test/java/com/arcadedb/server/http/handler/ExecutionResponseTest.java b/server/src/test/java/com/arcadedb/server/http/handler/ExecutionResponseTest.java new file mode 100644 index 0000000000..7eb912d1cf --- /dev/null +++ b/server/src/test/java/com/arcadedb/server/http/handler/ExecutionResponseTest.java @@ -0,0 +1,130 @@ +/* + * Copyright 2021-present Arcade Data Ltd (info@arcadedata.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com) + * SPDX-License-Identifier: Apache-2.0 + */ +package com.arcadedb.server.http.handler; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for ExecutionResponse. + */ +class ExecutionResponseTest { + + @Test + void createWithStringResponse() { + final ExecutionResponse response = new ExecutionResponse(200, "success"); + + assertThat(response.getCode()).isEqualTo(200); + assertThat(response.getResponse()).isEqualTo("success"); + assertThat(response.getBinary()).isNull(); + assertThat(response.isBinary()).isFalse(); + } + + @Test + void createWithBinaryResponse() { + final byte[] data = {0x01, 0x02, 0x03, 0x04}; + final ExecutionResponse response = new ExecutionResponse(201, data); + + assertThat(response.getCode()).isEqualTo(201); + assertThat(response.getResponse()).isNull(); + assertThat(response.getBinary()).isEqualTo(data); + assertThat(response.isBinary()).isTrue(); + } + + @Test + void createWithDifferentStatusCodes() { + final ExecutionResponse ok = new ExecutionResponse(200, "ok"); + final ExecutionResponse created = new ExecutionResponse(201, "created"); + final ExecutionResponse badRequest = new ExecutionResponse(400, "bad request"); + final ExecutionResponse serverError = new ExecutionResponse(500, "error"); + + assertThat(ok.getCode()).isEqualTo(200); + assertThat(created.getCode()).isEqualTo(201); + assertThat(badRequest.getCode()).isEqualTo(400); + assertThat(serverError.getCode()).isEqualTo(500); + } + + @Test + void createWithEmptyStringResponse() { + final ExecutionResponse response = new ExecutionResponse(204, ""); + + assertThat(response.getCode()).isEqualTo(204); + assertThat(response.getResponse()).isEmpty(); + assertThat(response.isBinary()).isFalse(); + } + + @Test + void createWithEmptyBinaryResponse() { + final ExecutionResponse response = new ExecutionResponse(204, new byte[0]); + + assertThat(response.getCode()).isEqualTo(204); + assertThat(response.getBinary()).isEmpty(); + assertThat(response.isBinary()).isTrue(); + } + + @Test + void createWithLargeStringResponse() { + final String largeResponse = "x".repeat(100000); + final ExecutionResponse response = new ExecutionResponse(200, largeResponse); + + assertThat(response.getCode()).isEqualTo(200); + assertThat(response.getResponse()).hasSize(100000); + assertThat(response.isBinary()).isFalse(); + } + + @Test + void createWithLargeBinaryResponse() { + final byte[] largeData = new byte[100000]; + final ExecutionResponse response = new ExecutionResponse(200, largeData); + + assertThat(response.getCode()).isEqualTo(200); + assertThat(response.getBinary()).hasSize(100000); + assertThat(response.isBinary()).isTrue(); + } + + @Test + void createWithJsonResponse() { + final String jsonResponse = "{\"status\":\"ok\",\"count\":42}"; + final ExecutionResponse response = new ExecutionResponse(200, jsonResponse); + + assertThat(response.getCode()).isEqualTo(200); + assertThat(response.getResponse()).isEqualTo(jsonResponse); + assertThat(response.getResponse()).contains("\"status\":\"ok\""); + assertThat(response.isBinary()).isFalse(); + } + + @Test + void createWithNullStringResponse() { + final ExecutionResponse response = new ExecutionResponse(200, (String) null); + + assertThat(response.getCode()).isEqualTo(200); + assertThat(response.getResponse()).isNull(); + assertThat(response.getBinary()).isNull(); + assertThat(response.isBinary()).isFalse(); + } + + @Test + void binaryResponsePreservesExactBytes() { + final byte[] data = {(byte) 0xFF, (byte) 0x00, (byte) 0xAB, (byte) 0xCD}; + final ExecutionResponse response = new ExecutionResponse(200, data); + + assertThat(response.getBinary()).containsExactly((byte) 0xFF, (byte) 0x00, (byte) 0xAB, (byte) 0xCD); + } +}