Skip to content

Commit 3a4f7ef

Browse files
Copilotxperiandri
andcommitted
Add comprehensive test coverage for documentId and validation cache
Agent-Logs-Url: https://github.com/fsprojects/FSharp.Data.GraphQL/sessions/fec46b15-afce-40da-85b4-306eae430ce0 Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com>
1 parent 71cd8d6 commit 3a4f7ef

5 files changed

Lines changed: 258 additions & 2 deletions

File tree

src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,10 @@ module SchemaId =
4242
let fromIntrospectionSchema (introspectionSchema : IntrospectionSchema) =
4343
use stream = new MemoryStream()
4444
JsonSerializer.Serialize(stream, introspectionSchema, jsonOptions)
45-
stream.Position <- 0L
4645
// Note: Creating SHA256 instance per call is acceptable since schema ID computation
4746
// happens infrequently (typically once per schema during validation cache key creation)
4847
use sha256 = SHA256.Create()
49-
let hash = sha256.ComputeHash stream
48+
let hash = sha256.ComputeHash(stream.ToArray())
5049
hash
5150
|> Seq.map formatByteAsLowerHex
5251
|> String.concat ""
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// The MIT License (MIT)
2+
// Copyright (c) 2016 Bazinga Technologies Inc
3+
4+
module FSharp.Data.GraphQL.Tests.DocumentIdTests
5+
6+
open Xunit
7+
open FSharp.Data.GraphQL
8+
9+
[<Fact>]
10+
let ``DocumentId.fromCanonicalQuery produces deterministic hash`` () =
11+
let query = "query Example { a b }"
12+
let hash1 = DocumentId.fromCanonicalQuery query
13+
let hash2 = DocumentId.fromCanonicalQuery query
14+
equals hash1 hash2
15+
equals 64 hash1.Length // SHA-256 hex string is 64 chars
16+
17+
[<Fact>]
18+
let ``DocumentId.fromCanonicalQuery produces different hashes for different queries`` () =
19+
let query1 = "query Example1 { a }"
20+
let query2 = "query Example2 { b }"
21+
let hash1 = DocumentId.fromCanonicalQuery query1
22+
let hash2 = DocumentId.fromCanonicalQuery query2
23+
notEquals hash1 hash2
24+
25+
[<Fact>]
26+
let ``DocumentId.fromCanonicalQuery handles empty string`` () =
27+
let query = ""
28+
let hash = DocumentId.fromCanonicalQuery query
29+
equals 64 hash.Length
30+
// SHA-256 of empty string
31+
equals "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" hash
32+
33+
[<Fact>]
34+
let ``DocumentId.fromCanonicalQuery produces lowercase hex`` () =
35+
let query = "query Test { field }"
36+
let hash = DocumentId.fromCanonicalQuery query
37+
equals hash (hash.ToLowerInvariant())
38+
Assert.True(hash |> Seq.forall (fun c -> (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')))
39+
40+
[<Fact>]
41+
let ``DocumentId.fromCanonicalQuery handles special characters in strings`` () =
42+
// Test with escaped characters that should be in the canonical form
43+
let query1 = """query Test { field(arg: "test\"quote") }"""
44+
let query2 = """query Test { field(arg: "test\nline") }"""
45+
let query3 = """query Test { field(arg: "test\ttab") }"""
46+
let hash1 = DocumentId.fromCanonicalQuery query1
47+
let hash2 = DocumentId.fromCanonicalQuery query2
48+
let hash3 = DocumentId.fromCanonicalQuery query3
49+
// All should produce valid hashes
50+
equals 64 hash1.Length
51+
equals 64 hash2.Length
52+
equals 64 hash3.Length
53+
// All should be different
54+
notEquals hash1 hash2
55+
notEquals hash2 hash3
56+
notEquals hash1 hash3
57+
58+
[<Fact>]
59+
let ``DocumentId.fromCanonicalQuery is consistent with known SHA-256 values`` () =
60+
// Test a simple known case
61+
let query = "test"
62+
let hash = DocumentId.fromCanonicalQuery query
63+
// SHA-256 of "test"
64+
equals "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" hash
65+

tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,54 @@ let ``Execution when querying returns unique document id with response`` () =
399399
equals errors1 errors2
400400
| response -> fail $"Expected a 'Direct' GQLResponse but got\n{response}"
401401

402+
[<Fact>]
403+
let ``Execution documentId handles escaped string values correctly`` () =
404+
let schema =
405+
Schema(Define.Object<TwiceTest>(
406+
"Type", [
407+
Define.Field("a", StringType, fun _ x -> x.A)
408+
Define.Field("b", IntType, fun _ x -> x.B)
409+
]))
410+
// Query with string containing special characters that need escaping
411+
let query = """query Example { a(arg: "test\"quote\nline\ttab\\backslash") }"""
412+
let result = sync <| Executor(schema).AsyncExecute(query, getMockInputContext, { A = "test"; B = 1 })
413+
// DocumentId should be deterministic and not empty
414+
result.DocumentId |> notEquals Unchecked.defaultof<string>
415+
result.DocumentId.Length |> equals 64 // SHA-256 hex string is always 64 chars
416+
417+
[<Fact>]
418+
let ``Execution documentId is different for different queries`` () =
419+
let schema =
420+
Schema(Define.Object<TwiceTest>(
421+
"Type", [
422+
Define.Field("a", StringType, fun _ x -> x.A)
423+
Define.Field("b", IntType, fun _ x -> x.B)
424+
]))
425+
let query1 = "query Example1 { a }"
426+
let query2 = "query Example2 { b }"
427+
let result1 = sync <| Executor(schema).AsyncExecute(query1, getMockInputContext, { A = "aa"; B = 2 })
428+
let result2 = sync <| Executor(schema).AsyncExecute(query2, getMockInputContext, { A = "aa"; B = 2 })
429+
result1.DocumentId |> notEquals result2.DocumentId
430+
431+
[<Fact>]
432+
let ``Execution documentId is same for semantically identical queries`` () =
433+
let schema =
434+
Schema(Define.Object<TwiceTest>(
435+
"Type", [
436+
Define.Field("a", StringType, fun _ x -> x.A)
437+
Define.Field("b", IntType, fun _ x -> x.B)
438+
]))
439+
// Same query with different whitespace/formatting
440+
let query1 = "query Example { a b }"
441+
let query2 = "query Example{a b}"
442+
let query3 = "query Example { a, b }"
443+
let result1 = sync <| Executor(schema).AsyncExecute(query1, getMockInputContext, { A = "aa"; B = 2 })
444+
let result2 = sync <| Executor(schema).AsyncExecute(query2, getMockInputContext, { A = "aa"; B = 2 })
445+
let result3 = sync <| Executor(schema).AsyncExecute(query3, getMockInputContext, { A = "aa"; B = 2 })
446+
// All should produce the same documentId since they parse to the same AST
447+
result1.DocumentId |> equals result2.DocumentId
448+
result1.DocumentId |> equals result3.DocumentId
449+
402450
type InnerNullableTest = { Kaboom : string }
403451
type NullableTest = {
404452
Inner : InnerNullableTest

tests/FSharp.Data.GraphQL.Tests/FSharp.Data.GraphQL.Tests.fsproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
<Compile Include="Helpers.fs" />
3131
<Compile Include="ErrorHelpers.fs" />
3232
<Compile Include="Literals.fs" />
33+
<Compile Include="DocumentIdTests.fs" />
34+
<Compile Include="ValidationCacheTests.fs" />
3335
<Compile Include="Relay\NodeTests.fs" />
3436
<Compile Include="Relay\CursorTests.fs" />
3537
<Compile Include="Relay\ConnectionTests.fs" />
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// The MIT License (MIT)
2+
// Copyright (c) 2016 Bazinga Technologies Inc
3+
4+
module FSharp.Data.GraphQL.Tests.ValidationCacheTests
5+
6+
open Xunit
7+
open FSharp.Data.GraphQL
8+
open FSharp.Data.GraphQL.Validation
9+
open System.Threading
10+
11+
[<Fact>]
12+
let ``MemoryValidationResultCache caches results for same key`` () =
13+
let cache = MemoryValidationResultCache() :> IValidationResultCache
14+
let mutable callCount = 0
15+
let producer () =
16+
Interlocked.Increment(&callCount) |> ignore
17+
Success
18+
19+
let key = { DocumentId = "doc1"; SchemaId = "schema1" }
20+
21+
// First call should invoke producer
22+
let result1 = cache.GetOrAdd producer key
23+
equals 1 callCount
24+
equals Success result1
25+
26+
// Second call with same key should NOT invoke producer (cached)
27+
let result2 = cache.GetOrAdd producer key
28+
equals 1 callCount // Still 1, not 2
29+
equals Success result2
30+
31+
[<Fact>]
32+
let ``MemoryValidationResultCache uses different cache entries for different DocumentIds`` () =
33+
let cache = MemoryValidationResultCache() :> IValidationResultCache
34+
let mutable callCount = 0
35+
let producer () =
36+
Interlocked.Increment(&callCount) |> ignore
37+
Success
38+
39+
let key1 = { DocumentId = "doc1"; SchemaId = "schema1" }
40+
let key2 = { DocumentId = "doc2"; SchemaId = "schema1" }
41+
42+
// First call
43+
let result1 = cache.GetOrAdd producer key1
44+
equals 1 callCount
45+
46+
// Second call with different DocumentId should invoke producer again
47+
let result2 = cache.GetOrAdd producer key2
48+
equals 2 callCount // Should be 2 now
49+
50+
[<Fact>]
51+
let ``MemoryValidationResultCache uses different cache entries for different SchemaIds`` () =
52+
let cache = MemoryValidationResultCache() :> IValidationResultCache
53+
let mutable callCount = 0
54+
let producer () =
55+
Interlocked.Increment(&callCount) |> ignore
56+
Success
57+
58+
let key1 = { DocumentId = "doc1"; SchemaId = "schema1" }
59+
let key2 = { DocumentId = "doc1"; SchemaId = "schema2" }
60+
61+
// First call
62+
let result1 = cache.GetOrAdd producer key1
63+
equals 1 callCount
64+
65+
// Second call with different SchemaId should invoke producer again
66+
let result2 = cache.GetOrAdd producer key2
67+
equals 2 callCount // Should be 2 now
68+
69+
[<Fact>]
70+
let ``MemoryValidationResultCache distinguishes keys with same hash code`` () =
71+
let cache = MemoryValidationResultCache() :> IValidationResultCache
72+
73+
// Create two different keys that might have hash collisions
74+
// Using very similar but different strings
75+
let key1 = { DocumentId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; SchemaId = "schema1" }
76+
let key2 = { DocumentId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab"; SchemaId = "schema1" }
77+
78+
let mutable callCount = 0
79+
let producer () =
80+
Interlocked.Increment(&callCount) |> ignore
81+
Success
82+
83+
// First call
84+
let result1 = cache.GetOrAdd producer key1
85+
equals 1 callCount
86+
87+
// Second call with different key should invoke producer even if hash codes collide
88+
let result2 = cache.GetOrAdd producer key2
89+
equals 2 callCount // Should be 2, proving we use full key not just hash
90+
91+
[<Fact>]
92+
let ``MemoryValidationResultCache caches error results`` () =
93+
let cache = MemoryValidationResultCache() :> IValidationResultCache
94+
let mutable callCount = 0
95+
let error = GQLProblemDetails.Create("Test error")
96+
let producer () =
97+
Interlocked.Increment(&callCount) |> ignore
98+
ValidationError [error]
99+
100+
let key = { DocumentId = "doc1"; SchemaId = "schema1" }
101+
102+
// First call should invoke producer
103+
let result1 = cache.GetOrAdd producer key
104+
equals 1 callCount
105+
match result1 with
106+
| ValidationError errors -> equals 1 (Seq.length errors)
107+
| Success -> fail "Expected ValidationError"
108+
109+
// Second call with same key should NOT invoke producer (cached)
110+
let result2 = cache.GetOrAdd producer key
111+
equals 1 callCount // Still 1, not 2
112+
match result2 with
113+
| ValidationError errors -> equals 1 (Seq.length errors)
114+
| Success -> fail "Expected ValidationError"
115+
116+
[<Fact>]
117+
let ``MemoryValidationResultCache handles concurrent access`` () =
118+
let cache = MemoryValidationResultCache() :> IValidationResultCache
119+
let mutable callCount = 0
120+
let producer () =
121+
Interlocked.Increment(&callCount) |> ignore
122+
Thread.Sleep(10) // Simulate some work
123+
Success
124+
125+
let key = { DocumentId = "doc1"; SchemaId = "schema1" }
126+
127+
// Call cache from multiple threads simultaneously
128+
let tasks =
129+
[1..10]
130+
|> List.map (fun _ ->
131+
async {
132+
return cache.GetOrAdd producer key
133+
})
134+
135+
let results = tasks |> Async.Parallel |> Async.RunSynchronously
136+
137+
// All results should be Success
138+
results |> Array.iter (fun r -> equals Success r)
139+
140+
// Producer should be called at least once, but possibly more due to race conditions
141+
// The important thing is it's not called 10 times
142+
Assert.True(callCount >= 1 && callCount < 10, $"Expected callCount between 1 and 9, got {callCount}")

0 commit comments

Comments
 (0)