|
| 1 | +// MIT License |
| 2 | +// |
| 3 | +// Copyright (c) 2025-present State Government of Victoria |
| 4 | +// |
| 5 | +// Permission is hereby granted, free of charge, to any person obtaining a copy |
| 6 | +// of this software and associated documentation files (the "Software"), to deal |
| 7 | +// in the Software without restriction, including without limitation the rights |
| 8 | +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
| 9 | +// copies of the Software, and to permit persons to whom the Software is |
| 10 | +// furnished to do so, subject to the following conditions: |
| 11 | +// |
| 12 | +// The above copyright notice and this permission notice shall be included in all |
| 13 | +// copies or substantial portions of the Software. |
| 14 | +// |
| 15 | +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
| 16 | +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
| 17 | +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
| 18 | +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
| 19 | +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
| 20 | +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
| 21 | +// SOFTWARE. |
| 22 | + |
| 23 | +/// FsCheck generators for property-based testing of domain types |
| 24 | +module Neo4jExport.Tests.Helpers.Generators |
| 25 | + |
| 26 | +open System |
| 27 | +open System.Collections.Generic |
| 28 | +open FsCheck |
| 29 | +open Neo4jExport |
| 30 | +open Neo4jExport.ExportTypes |
| 31 | + |
| 32 | +/// Generator for valid Neo4j labels (non-empty strings without special chars) |
| 33 | +let labelGen: Gen<string> = |
| 34 | + gen { |
| 35 | + let! chars = |
| 36 | + Gen.arrayOfLength |
| 37 | + 10 |
| 38 | + (Gen.elements [ 'A' .. 'Z' ] |
| 39 | + |> Gen.map Char.ToLower) |
| 40 | + |
| 41 | + let label = String(chars) |
| 42 | + return if String.IsNullOrWhiteSpace(label) then "Label" else label |
| 43 | + } |
| 44 | + |
| 45 | +/// Generator for sets of Neo4j labels |
| 46 | +let labelSetGen: Gen<Set<string>> = |
| 47 | + gen { |
| 48 | + let! count = Gen.choose (0, 5) |
| 49 | + let! labels = Gen.listOfLength count labelGen |
| 50 | + return Set.ofList labels |
| 51 | + } |
| 52 | + |
| 53 | +/// Generator for valid Neo4j property keys |
| 54 | +let propertyKeyGen: Gen<string> = |
| 55 | + gen { |
| 56 | + let! prefix = Gen.elements [ "prop"; "attr"; "field"; "value" ] |
| 57 | + let! suffix = Gen.choose (1, 100) |
| 58 | + return $"{prefix}{suffix}" |
| 59 | + } |
| 60 | + |
| 61 | +/// Generator for Neo4j primitive values |
| 62 | +let primitiveValueGen: Gen<obj> = |
| 63 | + Gen.oneof |
| 64 | + [ Gen.constant (box null) |
| 65 | + Gen.map box Arb.generate<bool> |
| 66 | + Gen.map box Arb.generate<int64> |
| 67 | + Gen.map box (Gen.filter Double.IsFinite Arb.generate<float>) // Only finite values |
| 68 | + Gen.map box (Gen.filter (fun s -> s <> null) Arb.generate<string>) ] |
| 69 | + |
| 70 | +/// Generator for Neo4j property dictionaries |
| 71 | +let propertyDictGen: Gen<IDictionary<string, obj>> = |
| 72 | + gen { |
| 73 | + let! count = Gen.choose (0, 10) |
| 74 | + let! keys = Gen.listOfLength count propertyKeyGen |
| 75 | + let! values = Gen.listOfLength count primitiveValueGen |
| 76 | + let dict = Dictionary<string, obj>() |
| 77 | + |
| 78 | + List.zip keys values |
| 79 | + |> List.iter (fun (k, v) -> dict.[k] <- v) |
| 80 | + |
| 81 | + return dict :> IDictionary<string, obj> |
| 82 | + } |
| 83 | + |
| 84 | +/// Generator for ExportConfig |
| 85 | +let exportConfigGen: Gen<ExportConfig> = |
| 86 | + gen { |
| 87 | + let! batchSize = Gen.choose (100, 50_000) |
| 88 | + let! maxMemory = Gen.choose (128, 4096) |> Gen.map int64 |
| 89 | + let! minDisk = Gen.choose (1, 100) |> Gen.map int64 |
| 90 | + let! enableHash = Arb.generate<bool> |
| 91 | + |
| 92 | + return |
| 93 | + { Uri = Uri("bolt://localhost:7687") |
| 94 | + User = "neo4j" |
| 95 | + Password = "testpass" |
| 96 | + OutputDirectory = "/tmp/test" |
| 97 | + MinDiskGb = minDisk |
| 98 | + MaxMemoryMb = maxMemory |
| 99 | + SkipSchemaCollection = false |
| 100 | + MaxRetries = 3 |
| 101 | + RetryDelayMs = 1000 |
| 102 | + MaxRetryDelayMs = 30000 |
| 103 | + QueryTimeoutSeconds = 300 |
| 104 | + EnableDebugLogging = false |
| 105 | + ValidateJsonOutput = false |
| 106 | + AllowInsecure = false |
| 107 | + BatchSize = batchSize |
| 108 | + JsonBufferSizeKb = 64 |
| 109 | + MaxPathLength = 100_000L |
| 110 | + PathFullModeLimit = 10L |
| 111 | + PathCompactModeLimit = 100L |
| 112 | + PathPropertyDepth = 2 |
| 113 | + MaxNestedDepth = 10 |
| 114 | + NestedShallowModeDepth = 2 |
| 115 | + NestedReferenceModeDepth = 4 |
| 116 | + MaxCollectionItems = 1000 |
| 117 | + MaxLabelsPerNode = 10 |
| 118 | + MaxLabelsInReferenceMode = 3 |
| 119 | + MaxLabelsInPathCompact = 5 |
| 120 | + EnableHashedIds = enableHash } |
| 121 | + } |
| 122 | + |
| 123 | +/// Coded error message types for safe testing |
| 124 | +type TestErrorMessage = |
| 125 | + | StandardError |
| 126 | + | ValidationError |
| 127 | + | ProcessingError |
| 128 | + | ResourceError |
| 129 | + | SystemError |
| 130 | + | DataError |
| 131 | + | OperationError |
| 132 | + | ServiceError |
| 133 | + | ConfigurationError |
| 134 | + | RuntimeError |
| 135 | + |
| 136 | +/// Convert coded error type to string |
| 137 | +let testMessageToString = |
| 138 | + function |
| 139 | + | StandardError -> "Standard error occurred" |
| 140 | + | ValidationError -> "Validation failed" |
| 141 | + | ProcessingError -> "Processing error" |
| 142 | + | ResourceError -> "Resource unavailable" |
| 143 | + | SystemError -> "System error" |
| 144 | + | DataError -> "Data error encountered" |
| 145 | + | OperationError -> "Operation failed" |
| 146 | + | ServiceError -> "Service error" |
| 147 | + | ConfigurationError -> "Configuration issue" |
| 148 | + | RuntimeError -> "Runtime error" |
| 149 | + |
| 150 | +/// Generator for coded error messages |
| 151 | +let testErrorMessageGen: Gen<TestErrorMessage> = |
| 152 | + Gen.elements |
| 153 | + [ StandardError |
| 154 | + ValidationError |
| 155 | + ProcessingError |
| 156 | + ResourceError |
| 157 | + SystemError |
| 158 | + DataError |
| 159 | + OperationError |
| 160 | + ServiceError |
| 161 | + ConfigurationError |
| 162 | + RuntimeError ] |
| 163 | + |
| 164 | +/// Generator for safe error messages that won't trigger content filters |
| 165 | +let safeStringGen: Gen<string> = |
| 166 | + Gen.map testMessageToString testErrorMessageGen |
| 167 | + |
| 168 | +/// Generator for test paths |
| 169 | +let testPathGen: Gen<string> = |
| 170 | + Gen.elements |
| 171 | + [ "/tmp/test" |
| 172 | + "/var/tmp/export" |
| 173 | + "/tmp/data" |
| 174 | + "/home/test/export" |
| 175 | + "/opt/app/data" ] |
| 176 | + |
| 177 | +/// Generator for entity types |
| 178 | +let entityTypeGen: Gen<string> = |
| 179 | + Gen.elements |
| 180 | + [ "Node" |
| 181 | + "Relationship" |
| 182 | + "Property" |
| 183 | + "Label" |
| 184 | + "Type" ] |
| 185 | + |
| 186 | +/// Generator for query patterns |
| 187 | +let queryPatternGen: Gen<string> = |
| 188 | + Gen.elements |
| 189 | + [ "MATCH (n)" |
| 190 | + "MATCH (n:Label)" |
| 191 | + "MATCH ()-[r]->()" |
| 192 | + "RETURN n" |
| 193 | + "WITH n" ] |
| 194 | + |
| 195 | +/// Generator for AppError variants (now includes all types with safe messages) |
| 196 | +let appErrorGen: Gen<AppError> = |
| 197 | + Gen.oneof |
| 198 | + [ Gen.map ConfigError safeStringGen |
| 199 | + Gen.map2 |
| 200 | + (fun msg exn -> ConnectionError(msg, Some exn)) |
| 201 | + safeStringGen |
| 202 | + (Gen.constant (Exception("Test exception"))) |
| 203 | + Gen.map AuthenticationError safeStringGen // Now included with safe message |
| 204 | + Gen.map3 |
| 205 | + (fun query msg exn -> QueryError(query, msg, Some exn)) |
| 206 | + queryPatternGen |
| 207 | + safeStringGen |
| 208 | + (Gen.constant (Exception("Query test"))) |
| 209 | + Gen.map3 |
| 210 | + (fun line msg sample -> DataCorruptionError(line, msg, Some sample)) |
| 211 | + (Gen.choose (1, 1000)) |
| 212 | + safeStringGen |
| 213 | + safeStringGen |
| 214 | + Gen.map2 |
| 215 | + (fun req avail -> DiskSpaceError(req, avail)) |
| 216 | + (Gen.choose (1000, 10000) |> Gen.map int64) |
| 217 | + (Gen.choose (100, 999) |> Gen.map int64) |
| 218 | + Gen.map MemoryError safeStringGen |
| 219 | + Gen.map2 (fun msg exn -> ExportError(msg, Some exn)) safeStringGen (Gen.constant (Exception("Export test"))) |
| 220 | + Gen.map3 |
| 221 | + (fun path msg exn -> FileSystemError(path, msg, Some exn)) |
| 222 | + testPathGen |
| 223 | + safeStringGen |
| 224 | + (Gen.constant (Exception("File test"))) |
| 225 | + Gen.map SecurityError safeStringGen // Now included with safe message |
| 226 | + Gen.map2 (fun op dur -> TimeoutError(op, TimeSpan.FromSeconds(float dur))) safeStringGen (Gen.choose (1, 300)) |
| 227 | + Gen.map2 (fun entityType msg -> PaginationError(entityType, msg)) entityTypeGen safeStringGen ] |
| 228 | + |
| 229 | +/// Generator for NonEmptyList of AppError (used for AggregateError) |
| 230 | +/// Note: We create a separate generator first to avoid infinite recursion |
| 231 | +let rec appErrorGenWithAggregate (depth: int) : Gen<AppError> = |
| 232 | + if depth > 2 then |
| 233 | + // At max depth, don't generate more AggregateErrors |
| 234 | + Gen.oneof |
| 235 | + [ Gen.map ConfigError (Gen.filter ((<>) null) Arb.generate<string>) |
| 236 | + Gen.map MemoryError (Gen.filter ((<>) null) Arb.generate<string>) |
| 237 | + Gen.map SecurityError (Gen.filter ((<>) null) Arb.generate<string>) ] |
| 238 | + else |
| 239 | + Gen.frequency |
| 240 | + [ (10, appErrorGen) // 10/11 chance of non-aggregate error |
| 241 | + (1, |
| 242 | + gen { // 1/11 chance of aggregate error |
| 243 | + let! head = appErrorGenWithAggregate (depth + 1) |
| 244 | + let! tailCount = Gen.choose (0, 2) |
| 245 | + let! tail = Gen.listOfLength tailCount (appErrorGenWithAggregate (depth + 1)) |
| 246 | + return AggregateError(NonEmptyList(head, tail)) |
| 247 | + }) ] |
| 248 | + |
| 249 | +/// Container type for custom Arbitrary instances |
| 250 | +type CustomGenerators = |
| 251 | + static member String() = |
| 252 | + Arb.Default.String() |> Arb.filter ((<>) null) |
| 253 | + |
| 254 | + static member ExportConfig() = Arb.fromGen exportConfigGen |
| 255 | + |
| 256 | + static member AppError() = |
| 257 | + Arb.fromGen (appErrorGenWithAggregate 0) |
| 258 | + |
| 259 | + static member PropertyDict() = Arb.fromGen propertyDictGen |
| 260 | + |
| 261 | +/// Generator for valid Neo4j property values including all supported types |
| 262 | +let neo4jValueGen: Gen<obj> = |
| 263 | + Gen.frequency |
| 264 | + [ (3, Gen.constant (box null)) |
| 265 | + (2, Gen.map box Arb.generate<bool>) |
| 266 | + (3, Gen.map box Arb.generate<int64>) |
| 267 | + (3, Gen.map box (Gen.filter Double.IsFinite Arb.generate<float>)) // Only finite values |
| 268 | + (5, Gen.map box (Gen.filter (fun s -> s <> null) Arb.generate<string>)) |
| 269 | + (1, Gen.map box Arb.generate<DateTime>) // Temporal types |
| 270 | + (1, Gen.map (fun arr -> box (arr: int64[])) (Gen.arrayOf Arb.generate<int64>)) ] // Arrays |
| 271 | + |
| 272 | +/// Generator for richer property dictionaries with various Neo4j types |
| 273 | +let richPropertyDictGen: Gen<IDictionary<string, obj>> = |
| 274 | + gen { |
| 275 | + let! count = Gen.choose (0, 20) // More properties for thorough testing |
| 276 | + let! keys = Gen.listOfLength count propertyKeyGen |
| 277 | + let distinctKeys = keys |> List.distinct |
| 278 | + let! values = Gen.listOfLength (List.length distinctKeys) neo4jValueGen |
| 279 | + let dict = Dictionary<string, obj>() |
| 280 | + |
| 281 | + List.zip distinctKeys values |
| 282 | + |> List.iter (fun (k, v) -> dict.[k] <- v) |
| 283 | + |
| 284 | + return dict :> IDictionary<string, obj> |
| 285 | + } |
| 286 | + |
| 287 | +/// Generator for node data (labels + properties) |
| 288 | +let nodeDataGen: Gen<Set<string> * IDictionary<string, obj>> = |
| 289 | + Gen.map2 (fun labels props -> (labels, props)) labelSetGen richPropertyDictGen |
| 290 | + |
| 291 | +/// Generator for element IDs |
| 292 | +let elementIdGen: Gen<string> = |
| 293 | + Gen.frequency |
| 294 | + [ (8, Gen.map (sprintf "element:%d") (Gen.choose (1, 10000))) |
| 295 | + (1, Gen.constant "") // Test empty element IDs |
| 296 | + (1, Gen.constant null) ] // Test null element IDs |
| 297 | + |
| 298 | +/// Generator for error info tuples |
| 299 | +let errorInfoGen: Gen<AppError * string> = |
| 300 | + Gen.map2 (fun error elemId -> (error, elemId)) appErrorGen elementIdGen |
| 301 | + |
| 302 | +/// Generator for pagination strategies |
| 303 | +let keysetIdGen: Gen<KeysetId> = |
| 304 | + Gen.oneof |
| 305 | + [ Gen.map NumericId (Gen.choose (1, 1000000) |> Gen.map int64) |
| 306 | + Gen.map ElementId (Gen.map (sprintf "element:%d") (Gen.choose (1, 1000000))) ] |
| 307 | + |
| 308 | +let neo4jVersionGen: Gen<Neo4jVersion> = |
| 309 | + Gen.elements [ V4x; V5x; V6x; Unknown ] |
| 310 | + |
| 311 | +let paginationStrategyGen: Gen<PaginationStrategy> = |
| 312 | + Gen.oneof |
| 313 | + [ Gen.map SkipLimit (Gen.choose (0, 10000)) |
| 314 | + Gen.map2 (fun lastId version -> Keyset(Some lastId, version)) keysetIdGen neo4jVersionGen ] |
| 315 | + |
| 316 | +/// Registers all custom generators with FsCheck |
| 317 | +let registerGenerators () = |
| 318 | + Arb.register<CustomGenerators> () |> ignore |
0 commit comments