Skip to content

Commit 873aab3

Browse files
committed
test(spring-boot): Move the Boot 3 GraphQL tests into the module
Ports the jakarta sample's three GraphQL system tests to the integrationTest suite as plain JSON requests, so they no longer need the Apollo client: queries, a schema mapping, a mutation, resolver errors, and fields loaded through a batch mapping and a data loader. Errors are matched by their exception and tied to their transaction by trace ID, which both keeps two error tests apart on the shared server and checks that the error belongs to its request's transaction. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent f7b8dbc commit 873aab3

7 files changed

Lines changed: 234 additions & 162 deletions

File tree

‎sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt‎

Lines changed: 0 additions & 46 deletions
This file was deleted.

‎sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt‎

Lines changed: 0 additions & 66 deletions
This file was deleted.

‎sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt‎

Lines changed: 0 additions & 50 deletions
This file was deleted.

‎sentry-spring-boot-jakarta/build.gradle.kts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ testing {
178178
implementation(platform(SpringBootPlugin.BOM_COORDINATES))
179179
implementation(projects.sentrySpringBootStarterJakarta)
180180
implementation(projects.sentryAsyncProfiler)
181+
implementation(projects.sentryGraphql22)
181182
implementation(projects.sentryJdbc)
182183
implementation(projects.sentryLogback)
183184
implementation(projects.sentryOkhttp)
@@ -194,6 +195,7 @@ testing {
194195
implementation(libs.openfeature)
195196
implementation(libs.springboot3.starter.aop)
196197
implementation(libs.springboot3.starter.cache)
198+
implementation(libs.springboot3.starter.graphql)
197199
implementation(libs.springboot3.starter.jdbc)
198200
implementation(libs.springboot3.starter.test)
199201
implementation(libs.springboot3.starter.web)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package io.sentry.spring.boot.jakarta.integrationtest
2+
3+
import java.util.UUID
4+
import java.util.concurrent.CompletableFuture
5+
import org.dataloader.DataLoader
6+
import org.springframework.graphql.data.method.annotation.Argument
7+
import org.springframework.graphql.data.method.annotation.BatchMapping
8+
import org.springframework.graphql.data.method.annotation.MutationMapping
9+
import org.springframework.graphql.data.method.annotation.QueryMapping
10+
import org.springframework.graphql.data.method.annotation.SchemaMapping
11+
import org.springframework.graphql.execution.BatchLoaderRegistry
12+
import org.springframework.stereotype.Controller
13+
import reactor.core.publisher.Mono
14+
15+
class Project(val slug: String)
16+
17+
enum class ProjectStatus {
18+
ACTIVE,
19+
COMMUNITY,
20+
}
21+
22+
class Task(val id: String, val name: String, val assigneeId: String?, val creatorId: String?)
23+
24+
class Assignee(val id: String, val name: String)
25+
26+
class Creator(val id: String, val name: String)
27+
28+
/** Resolvers that throw when given a "crash" argument, so tests can check error reporting. */
29+
@Controller
30+
open class ProjectController {
31+
@QueryMapping
32+
open fun greeting(@Argument name: String): String {
33+
if (name == "crash") {
34+
throw RuntimeException("causing an error for $name")
35+
}
36+
return "Hello $name!"
37+
}
38+
39+
@QueryMapping open fun project(@Argument slug: String): Project = Project(slug)
40+
41+
@SchemaMapping(typeName = "Project", field = "status")
42+
open fun status(project: Project): ProjectStatus = ProjectStatus.COMMUNITY
43+
44+
@MutationMapping
45+
open fun addProject(@Argument slug: String): String {
46+
if (slug == "addprojectcrash") {
47+
throw RuntimeException("causing a project add error for $slug")
48+
}
49+
return UUID.randomUUID().toString()
50+
}
51+
52+
@QueryMapping
53+
open fun tasks(@Argument projectSlug: String): List<Task> =
54+
listOf(
55+
Task("T1", "Create a new API", "A3", "C3"),
56+
Task("T2", "Update dependencies", "A1", "C1"),
57+
Task("T3", "Document API", "A1", "C1"),
58+
Task("T4", "Merge community PRs", "A2", "C2"),
59+
Task("T5", "Plan more work", null, null),
60+
)
61+
}
62+
63+
/** Loads a task's assignee with a batch mapping and its creator with a data loader. */
64+
@Controller
65+
open class TaskRelationsController(batchLoaderRegistry: BatchLoaderRegistry) {
66+
init {
67+
batchLoaderRegistry
68+
.forTypePair(String::class.java, Creator::class.java)
69+
.registerMappedBatchLoader { ids, _ ->
70+
Mono.just(ids.associateWith { Creator(it, "Name$it") })
71+
}
72+
}
73+
74+
@BatchMapping(typeName = "Task", field = "assignee")
75+
open fun assignee(tasks: List<Task>): Map<Task, Assignee> =
76+
tasks
77+
.filter { it.assigneeId != null }
78+
.associateWith { Assignee(it.assigneeId!!, "Name${it.assigneeId}") }
79+
80+
@SchemaMapping(typeName = "Task")
81+
open fun creator(task: Task, creators: DataLoader<String, Creator>): CompletableFuture<Creator>? =
82+
task.creatorId?.let { creators.load(it) }
83+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package io.sentry.spring.boot.jakarta.integrationtest
2+
3+
import com.fasterxml.jackson.databind.JsonNode
4+
import com.google.common.truth.Truth.assertThat
5+
import io.sentry.protocol.SentryTransaction
6+
import io.sentry.test.event
7+
import io.sentry.test.hasSpanWithOpAndDescription
8+
import io.sentry.test.transaction
9+
import kotlin.test.Test
10+
11+
class GraphqlIntegrationTest : IntegrationTest() {
12+
13+
@Test
14+
fun `query is traced`() {
15+
val response = graphql("""query { greeting(name: "world") }""")
16+
17+
assertThat(response.has("errors")).isFalse()
18+
assertThat(response["data"]["greeting"].asText()).isEqualTo("Hello world!")
19+
awaitTransactionWithSpan("Query.greeting")
20+
}
21+
22+
@Test
23+
fun `resolver error is reported with its transaction`() {
24+
val response = graphql("""query { greeting(name: "crash") }""")
25+
26+
assertThat(response["errors"].size()).isEqualTo(1)
27+
val transaction = awaitTransactionOfError("causing an error for crash")
28+
assertThat(transaction.hasSpanWithOpAndDescription("graphql", "Query.greeting")).isTrue()
29+
}
30+
31+
@Test
32+
fun `query with a schema mapping is traced`() {
33+
val response = graphql("""query { project(slug: "proj-slug") { slug status } }""")
34+
35+
assertThat(response.has("errors")).isFalse()
36+
assertThat(response["data"]["project"]["slug"].asText()).isEqualTo("proj-slug")
37+
awaitTransactionWithSpan("Query.project")
38+
}
39+
40+
@Test
41+
fun `mutation is traced`() {
42+
val response = graphql("""mutation { addProject(slug: "proj-slug") }""")
43+
44+
assertThat(response.has("errors")).isFalse()
45+
assertThat(response["data"]["addProject"].asText()).isNotEmpty()
46+
awaitTransactionWithSpan("Mutation.addProject")
47+
}
48+
49+
@Test
50+
fun `mutation error is reported with its transaction`() {
51+
val response = graphql("""mutation { addProject(slug: "addprojectcrash") }""")
52+
53+
assertThat(response["errors"].size()).isEqualTo(1)
54+
assertThat(response.path("data").path("addProject").asText(null)).isNull()
55+
val transaction = awaitTransactionOfError("causing a project add error for addprojectcrash")
56+
assertThat(transaction.hasSpanWithOpAndDescription("graphql", "Mutation.addProject")).isTrue()
57+
}
58+
59+
@Test
60+
fun `batch loaded fields are traced`() {
61+
val response =
62+
graphql(
63+
"""query { tasks(projectSlug: "project-slug") { id assigneeId assignee { id } creatorId creator { id } } }"""
64+
)
65+
66+
assertThat(response.has("errors")).isFalse()
67+
val tasks = response["data"]["tasks"]
68+
assertThat(tasks.size()).isEqualTo(5)
69+
assertThat(tasks[0]["id"].asText()).isEqualTo("T1")
70+
assertThat(tasks[0]["assignee"]["id"].asText()).isEqualTo("A3")
71+
assertThat(tasks[0]["creator"]["id"].asText()).isEqualTo("C3")
72+
73+
val transaction = awaitTransactionWithSpan("Query.tasks")
74+
assertThat(transaction.hasSpanWithOpAndDescription("graphql", "Task.assignee")).isTrue()
75+
assertThat(transaction.hasSpanWithOpAndDescription("graphql", "Task.creator")).isTrue()
76+
}
77+
78+
private fun graphql(query: String): JsonNode {
79+
val response =
80+
restTemplate.postForEntity("/graphql", mapOf("query" to query), JsonNode::class.java)
81+
assertThat(response.statusCode.value()).isEqualTo(200)
82+
return response.body!!
83+
}
84+
85+
private fun awaitTransactionWithSpan(description: String): SentryTransaction =
86+
server
87+
.awaitEnvelope {
88+
it.transaction()?.let { t ->
89+
t.transaction == "POST /graphql" &&
90+
t.spans.any { span -> span.op == "graphql" && span.description == description }
91+
} == true
92+
}
93+
.transaction()!!
94+
95+
/** Finds the error for [exceptionMessage], then the transaction it belongs to by trace ID. */
96+
private fun awaitTransactionOfError(exceptionMessage: String): SentryTransaction {
97+
val error =
98+
server
99+
.awaitEnvelope { envelope ->
100+
envelope.event()?.let { event ->
101+
event.message?.message?.startsWith("Unresolved RuntimeException for executionId ") ==
102+
true && event.exceptions?.any { it.value == exceptionMessage } == true
103+
} == true
104+
}
105+
.event()!!
106+
val traceId = error.contexts.trace!!.traceId
107+
return server
108+
.awaitEnvelope { it.transaction()?.contexts?.trace?.traceId == traceId }
109+
.transaction()!!
110+
}
111+
}

0 commit comments

Comments
 (0)