diff --git a/.claude/instructions.md b/.claude/instructions.md new file mode 100644 index 00000000..6eebb70c --- /dev/null +++ b/.claude/instructions.md @@ -0,0 +1,101 @@ +# SDK Maintenance Instructions + +## Your Role +You are an expert at Kotlin and Java and maintain the Nylas Kotlin/Java SDK which is an interface for Nylas' APIs. + +Your job is to maintain parity with the API to ensure that the SDK supports all that the API supports. + +## Task Workflow + +When asked to add support for a new feature, query parameter, or API endpoint, follow this workflow: + +### 1. Understand the Codebase +- Scan the project structure +- Understand the architecture and how it's organized +- Identify existing patterns and conventions +- Review similar existing implementations + +### 2. Write Tests First (TDD) +- Implement tests for the new feature BEFORE writing implementation code +- Ensure backward compatibility is maintained +- **Never modify existing tests unless absolutely necessary** +- **IF BACKWARDS COMPATIBILITY CANNOT BE ACHIEVED, STOP AND INFORM THE USER** + +### 3. Implement the Solution +- Follow existing code patterns and conventions +- Maintain consistency with the rest of the codebase +- Keep changes minimal and focused +- Only modify production code in `src/main/` +- Only modify test code in `src/test/` + +### 4. Update the CHANGELOG +- Update `CHANGELOG.md` following conventional commits format +- Always add changes under an "Unreleased" version section +- Keep updates concise - one line per change +- Never include code examples in the changelog +- Format: `* Brief description of the change` + +### 5. Create Examples (if applicable) +- If the feature is user-facing, create an example in `examples/` +- Follow the pattern of existing examples +- Provide both Java and Kotlin examples when relevant + +### 6. Run Linters and Fix Formatting +- Run: `./gradlew formatKotlin` +- Run: `./gradlew lintKotlin` +- Fix any formatting or linting errors + +### 7. Verify Tests Pass +- Run: `./gradlew test` +- Ensure all tests pass before proceeding + +### 8. Git Commit +- Follow conventional commits format +- Format: `type(scope): description` +- Types: `feat`, `fix`, `docs`, `test`, `refactor`, `chore` +- Examples: + - `feat(folders): add include_hidden_folders query parameter` + - `fix(messages): correct attachment encoding for files < 3MB` + - `test(calendars): add coverage for event recurrence` + +## Important Notes + +- **Production Code Safety**: The SDK is live in production. Never break existing functionality. +- **Test Coverage**: Aim to maintain or improve test coverage. Current target is 99% by Q3. +- **Backward Compatibility**: This is critical. If you can't maintain it, stop and inform the user. +- **Java Version**: Project uses Java 11 (toolchain set to Java 8 for compatibility) +- **JAVA_HOME**: `/Users/gordan.o@nylas.com/Library/Java/JavaVirtualMachines/temurin-11.0.29/Contents/Home` + +## Code Patterns + +### Query Parameters +Query parameters are defined in `src/main/kotlin/com/nylas/models/*QueryParams.kt` files using: +- Data classes with nullable properties +- Builder pattern for construction +- `@Json(name = "...")` annotations for JSON serialization + +### Resource Methods +Resource classes in `src/main/kotlin/com/nylas/resources/*.kt` follow this pattern: +- Inherit from `Resource(client)` +- Use HTTP methods: `list()`, `find()`, `create()`, `update()`, `destroy()` +- Accept query params as optional parameters +- Return typed responses: `Response`, `ListResponse`, `DeleteResponse` + +### Tests +Test files in `src/test/kotlin/com/nylas/resources/*Tests.kt` use: +- Mockito for HTTP mocking +- JUnit 5 for test framework +- Nested test classes for organization +- Pattern: mock HTTP client → execute method → verify request → assert response + +## Quality Checklist + +Before considering a task complete: +- [ ] Tests written and passing +- [ ] Implementation follows existing patterns +- [ ] CHANGELOG.md updated +- [ ] Examples created (if applicable) +- [ ] Linters pass +- [ ] All tests pass +- [ ] Backward compatibility maintained +- [ ] Git commit created with conventional commit message diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..01d9d051 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,16 @@ +{ + "permissions": { + "allow": [ + "Bash(export JAVA_HOME=/Users/gordan.o@nylas.com/Library/Java/JavaVirtualMachines/corretto-11.0.29/Contents/Home:*)", + "Bash(./gradlew test:*)", + "Bash(export JAVA_HOME:*)", + "Bash(./gradlew jacocoTestReport:*)", + "Bash(open build/reports/jacoco/test/html/index.html)", + "Bash(cat:*)", + "Bash(find:*)", + "Bash(./gradlew clean build:*)", + "Bash(java -version:*)", + "Bash(/usr/libexec/java_home:*)" + ] + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 58a14017..910880fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Nylas Java SDK Changelog +## [Unreleased] + +### Added +* Client-side validation for `CreateEventRequest.When.Timespan` and `UpdateEventRequest.When.Timespan` to ensure `endTime` is after `startTime`, preventing confusing API errors +* `validationErrors` field to `NylasApiError` to capture field-level validation errors from the API +* Enhanced error messages in `NylasApiError.toString()` to display validation errors, provider errors, and request IDs for easier debugging + +### Fixed +* Improved error handling in `NylasClient` to provide more descriptive error messages when API responses cannot be parsed, including response body preview and status codes + ## [2.14.0] ### Added diff --git a/build.gradle.kts b/build.gradle.kts index c44fed2d..73799bd0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,7 +16,6 @@ repositories { java { sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 withJavadocJar() withSourcesJar() } diff --git a/src/main/kotlin/com/nylas/NylasClient.kt b/src/main/kotlin/com/nylas/NylasClient.kt index 6a97af96..fa864645 100644 --- a/src/main/kotlin/com/nylas/NylasClient.kt +++ b/src/main/kotlin/com/nylas/NylasClient.kt @@ -433,9 +433,15 @@ open class NylasClient( } catch (ex: Exception) { when (ex) { is IOException, is JsonDataException -> { + // Attempt to extract useful error information even from malformed responses + val errorMessage = if (responseBody.isNotBlank()) { + "API request failed with status ${response.code}. Response body: ${responseBody.take(500)}" + } else { + "API request failed with status ${response.code} and empty response body" + } throw NylasApiError( type = "unknown", - message = "Unknown error received from the API: $responseBody", + message = errorMessage, statusCode = response.code, headers = response.headers.toMultimap(), ) @@ -451,9 +457,15 @@ open class NylasClient( throw parsedError } + // Final fallback if parsing succeeded but returned null + val errorMessage = if (responseBody.isNotBlank()) { + "API request failed with status ${response.code}. Response body: ${responseBody.take(500)}" + } else { + "API request failed with status ${response.code} and empty response body" + } throw NylasApiError( type = "unknown", - message = "Unknown error received from the API: $responseBody", + message = errorMessage, statusCode = response.code, headers = response.headers.toMultimap(), ) diff --git a/src/main/kotlin/com/nylas/models/CreateEventRequest.kt b/src/main/kotlin/com/nylas/models/CreateEventRequest.kt index 22675655..eac50393 100644 --- a/src/main/kotlin/com/nylas/models/CreateEventRequest.kt +++ b/src/main/kotlin/com/nylas/models/CreateEventRequest.kt @@ -235,8 +235,17 @@ data class CreateEventRequest( /** * Builds the [Timespan] object. * @return [Timespan] object. + * @throws IllegalArgumentException if endTime is not after startTime. */ - fun build() = Timespan(startTime, endTime, startTimezone, endTimezone) + fun build(): Timespan { + // Validate that endTime must be after startTime + require(endTime > startTime) { + "Invalid Timespan: endTime ($endTime) must be after startTime ($startTime). " + + "Timespan events require a positive duration. " + + "For point-in-time events, use CreateEventRequest.When.Time instead." + } + return Timespan(startTime, endTime, startTimezone, endTimezone) + } } } } diff --git a/src/main/kotlin/com/nylas/models/NylasApiError.kt b/src/main/kotlin/com/nylas/models/NylasApiError.kt index fd38faaa..2555c237 100644 --- a/src/main/kotlin/com/nylas/models/NylasApiError.kt +++ b/src/main/kotlin/com/nylas/models/NylasApiError.kt @@ -21,6 +21,12 @@ data class NylasApiError( */ @Json(name = "provider_error") val providerError: Map? = null, + /** + * Field-level validation errors from the API. + * Maps field names to their specific error messages. + */ + @Json(name = "validation_errors") + val validationErrors: Map? = null, /** * The HTTP status code of the error response */ @@ -33,4 +39,42 @@ data class NylasApiError( * The HTTP headers of the error response */ override var headers: Map>? = null, -) : AbstractNylasApiError(message, statusCode, requestId, headers) +) : AbstractNylasApiError(message, statusCode, requestId, headers) { + + /** + * Formats the error as a human-readable string. + * + * Example output: + * ``` + * NylasApiError: Bad Request (HTTP 400) + * Validation errors: + * - when.end_time: must be after start_time + * Request ID: abc-123 + * ``` + */ + override fun toString(): String { + val sb = StringBuilder() + sb.append("NylasApiError: $message") + + if (statusCode != null) { + sb.append(" (HTTP $statusCode)") + } + + if (!validationErrors.isNullOrEmpty()) { + sb.append("\nValidation errors:") + validationErrors.forEach { (field, error) -> + sb.append("\n - $field: $error") + } + } + + if (providerError != null) { + sb.append("\nProvider error: $providerError") + } + + if (requestId != null) { + sb.append("\nRequest ID: $requestId") + } + + return sb.toString() + } +} diff --git a/src/main/kotlin/com/nylas/models/UpdateEventRequest.kt b/src/main/kotlin/com/nylas/models/UpdateEventRequest.kt index 98961616..a97f3bed 100644 --- a/src/main/kotlin/com/nylas/models/UpdateEventRequest.kt +++ b/src/main/kotlin/com/nylas/models/UpdateEventRequest.kt @@ -277,8 +277,22 @@ data class UpdateEventRequest( /** * Builds the [Timespan] object. * @return [Timespan] object. + * @throws IllegalArgumentException if both startTime and endTime are provided and endTime is not after startTime. */ - fun build() = Timespan(startTime, endTime, startTimezone, endTimezone) + fun build(): Timespan { + // Validate that if both times are set, endTime must be after startTime + // Local variables for smart-cast null safety + val start = startTime + val end = endTime + if (start != null && end != null) { + require(end > start) { + "Invalid Timespan: endTime ($end) must be after startTime ($start). " + + "Timespan events require a positive duration. " + + "For point-in-time events, use UpdateEventRequest.When.Time instead." + } + } + return Timespan(startTime, endTime, startTimezone, endTimezone) + } } } } diff --git a/src/test/kotlin/com/nylas/NylasClientTest.kt b/src/test/kotlin/com/nylas/NylasClientTest.kt index 65ad3846..c9131de1 100644 --- a/src/test/kotlin/com/nylas/NylasClientTest.kt +++ b/src/test/kotlin/com/nylas/NylasClientTest.kt @@ -317,7 +317,8 @@ class NylasClientTest { } assertEquals("unknown", exception.type) - assertEquals("Unknown error received from the API: not a json", exception.message) + assert(exception.message.contains("API request failed with status 400")) + assert(exception.message.contains("not a json")) assertEquals(400, exception.statusCode) assertEquals(headersMultiMap, exception.headers) } @@ -559,4 +560,206 @@ class NylasClientTest { assertEquals("Content-Type", NylasClient.HttpHeaders.CONTENT_TYPE.headerName) } } + + @Nested + inner class ErrorParsingTests { + private val mockHttpClient: OkHttpClient = mock(OkHttpClient::class.java) + private val mockCall: Call = mock(Call::class.java) + private val mockResponseBody: ResponseBody = mock(ResponseBody::class.java) + private val mockOkHttpClientBuilder: OkHttpClient.Builder = mock() + + @BeforeEach + fun setup() { + MockitoAnnotations.openMocks(this) + whenever(mockOkHttpClientBuilder.addInterceptor(any())).thenReturn(mockOkHttpClientBuilder) + whenever(mockOkHttpClientBuilder.build()).thenReturn(mockHttpClient) + whenever(mockHttpClient.newCall(any())).thenReturn(mockCall) + } + + @Test + fun `API 400 error with validation details is parsed correctly`() { + val errorJson = """ + { + "error": { + "type": "invalid_request", + "message": "Event validation failed", + "validation_errors": { + "when.end_time": "must be after start_time" + } + }, + "request_id": "req_abc123" + } + """.trimIndent() + + val mockResponse = mock(okhttp3.Response::class.java) + val mockBody = mock(ResponseBody::class.java) + + whenever(mockResponse.isSuccessful).thenReturn(false) + whenever(mockResponse.code).thenReturn(400) + whenever(mockResponse.body).thenReturn(mockBody) + whenever(mockBody.string()).thenReturn(errorJson) + whenever(mockResponse.headers).thenReturn(Headers.headersOf()) + whenever(mockCall.execute()).thenReturn(mockResponse) + + val client = NylasClient("test-api-key", mockOkHttpClientBuilder) + + val exception = assertFailsWith { + client.grants().list() + } + + assertEquals("invalid_request", exception.type) + assertEquals("Event validation failed", exception.message) + assertEquals(mapOf("when.end_time" to "must be after start_time"), exception.validationErrors) + assertEquals("req_abc123", exception.requestId) + assertEquals(400, exception.statusCode) + } + + @Test + fun `API 400 error without validation details is parsed correctly`() { + val errorJson = """ + { + "error": { + "type": "invalid_request", + "message": "Bad request" + }, + "request_id": "req_xyz789" + } + """.trimIndent() + + val mockResponse = mock(okhttp3.Response::class.java) + val mockBody = mock(ResponseBody::class.java) + + whenever(mockResponse.isSuccessful).thenReturn(false) + whenever(mockResponse.code).thenReturn(400) + whenever(mockResponse.body).thenReturn(mockBody) + whenever(mockBody.string()).thenReturn(errorJson) + whenever(mockResponse.headers).thenReturn(Headers.headersOf()) + whenever(mockCall.execute()).thenReturn(mockResponse) + + val client = NylasClient("test-api-key", mockOkHttpClientBuilder) + + val exception = assertFailsWith { + client.grants().list() + } + + assertEquals("invalid_request", exception.type) + assertEquals("Bad request", exception.message) + assertEquals(null, exception.validationErrors) + assertEquals("req_xyz789", exception.requestId) + } + + @Test + fun `Malformed API error response returns helpful fallback message`() { + val malformedJson = """{"invalid": "json structure""" + + val mockResponse = mock(okhttp3.Response::class.java) + val mockBody = mock(ResponseBody::class.java) + + whenever(mockResponse.isSuccessful).thenReturn(false) + whenever(mockResponse.code).thenReturn(400) + whenever(mockResponse.body).thenReturn(mockBody) + whenever(mockBody.string()).thenReturn(malformedJson) + whenever(mockResponse.headers).thenReturn(Headers.headersOf()) + whenever(mockCall.execute()).thenReturn(mockResponse) + + val client = NylasClient("test-api-key", mockOkHttpClientBuilder) + + val exception = assertFailsWith { + client.grants().list() + } + + assertEquals("unknown", exception.type) + assert(exception.message.contains("API request failed with status 400")) + assert(exception.message.contains("Response body:")) + assertEquals(400, exception.statusCode) + } + + @Test + fun `Empty response body returns helpful fallback message`() { + val mockResponse = mock(okhttp3.Response::class.java) + val mockBody = mock(ResponseBody::class.java) + + whenever(mockResponse.isSuccessful).thenReturn(false) + whenever(mockResponse.code).thenReturn(500) + whenever(mockResponse.body).thenReturn(mockBody) + whenever(mockBody.string()).thenReturn("") + whenever(mockResponse.headers).thenReturn(Headers.headersOf()) + whenever(mockCall.execute()).thenReturn(mockResponse) + + val client = NylasClient("test-api-key", mockOkHttpClientBuilder) + + val exception = assertFailsWith { + client.grants().list() + } + + assertEquals("unknown", exception.type) + assert(exception.message.contains("API request failed with status 500 and empty response body")) + assertEquals(500, exception.statusCode) + } + + @Test + fun `API error with provider error is parsed correctly`() { + val errorJson = """ + { + "error": { + "type": "provider_error", + "message": "Provider returned an error", + "provider_error": { + "error": "invalid_grant", + "error_description": "Token has been revoked" + } + }, + "request_id": "req_provider123" + } + """.trimIndent() + + val mockResponse = mock(okhttp3.Response::class.java) + val mockBody = mock(ResponseBody::class.java) + + whenever(mockResponse.isSuccessful).thenReturn(false) + whenever(mockResponse.code).thenReturn(400) + whenever(mockResponse.body).thenReturn(mockBody) + whenever(mockBody.string()).thenReturn(errorJson) + whenever(mockResponse.headers).thenReturn(Headers.headersOf()) + whenever(mockCall.execute()).thenReturn(mockResponse) + + val client = NylasClient("test-api-key", mockOkHttpClientBuilder) + + val exception = assertFailsWith { + client.grants().list() + } + + assertEquals("provider_error", exception.type) + assertEquals("Provider returned an error", exception.message) + assertNotNull(exception.providerError) + assertEquals("invalid_grant", exception.providerError!!["error"]) + } + + @Test + fun `API error with valid JSON but null error object returns helpful fallback message`() { + // This tests the final fallback path when JSON parsing succeeds but returns null + val validJsonButNullError = """{"some_field": "some_value"}""" + + val mockResponse = mock(okhttp3.Response::class.java) + val mockBody = mock(ResponseBody::class.java) + + whenever(mockResponse.isSuccessful).thenReturn(false) + whenever(mockResponse.code).thenReturn(503) + whenever(mockResponse.body).thenReturn(mockBody) + whenever(mockBody.string()).thenReturn(validJsonButNullError) + whenever(mockResponse.headers).thenReturn(Headers.headersOf()) + whenever(mockCall.execute()).thenReturn(mockResponse) + + val client = NylasClient("test-api-key", mockOkHttpClientBuilder) + + val exception = assertFailsWith { + client.grants().list() + } + + assertEquals("unknown", exception.type) + assert(exception.message.contains("API request failed with status 503")) + assert(exception.message.contains("Response body:")) + assertEquals(503, exception.statusCode) + } + } } diff --git a/src/test/kotlin/com/nylas/resources/EventsTests.kt b/src/test/kotlin/com/nylas/resources/EventsTests.kt index d343fed6..5dbfcd7a 100644 --- a/src/test/kotlin/com/nylas/resources/EventsTests.kt +++ b/src/test/kotlin/com/nylas/resources/EventsTests.kt @@ -496,6 +496,199 @@ class EventsTests { } } + @Nested + inner class ValidationTests { + @Test + fun `UpdateEventRequest Timespan with valid duration succeeds`() { + val timespan = UpdateEventRequest.When.Timespan.Builder() + .startTime(1620000000) + .endTime(1620003600) + .build() + + assertEquals(1620000000, timespan.startTime) + assertEquals(1620003600, timespan.endTime) + } + + @Test + fun `UpdateEventRequest Timespan with zero duration throws exception`() { + val exception = org.junit.jupiter.api.assertThrows { + UpdateEventRequest.When.Timespan.Builder() + .startTime(1620000000) + .endTime(1620000000) + .build() + } + + assert(exception.message!!.contains("endTime (1620000000) must be after startTime (1620000000)")) + assert(exception.message!!.contains("For point-in-time events, use UpdateEventRequest.When.Time instead")) + } + + @Test + fun `UpdateEventRequest Timespan with negative duration throws exception`() { + val exception = org.junit.jupiter.api.assertThrows { + UpdateEventRequest.When.Timespan.Builder() + .startTime(1620003600) + .endTime(1620000000) + .build() + } + + assert(exception.message!!.contains("endTime (1620000000) must be after startTime (1620003600)")) + assert(exception.message!!.contains("Timespan events require a positive duration")) + } + + @Test + fun `UpdateEventRequest Timespan with null endTime allows creation`() { + val timespan = UpdateEventRequest.When.Timespan.Builder() + .startTime(1620000000) + .build() + + assertEquals(1620000000, timespan.startTime) + assertEquals(null, timespan.endTime) + } + + @Test + fun `UpdateEventRequest Timespan with null startTime allows creation`() { + val timespan = UpdateEventRequest.When.Timespan.Builder() + .endTime(1620003600) + .build() + + assertEquals(null, timespan.startTime) + assertEquals(1620003600, timespan.endTime) + } + + @Test + fun `UpdateEventRequest Timespan with both null times allows creation`() { + val timespan = UpdateEventRequest.When.Timespan.Builder() + .build() + + assertEquals(null, timespan.startTime) + assertEquals(null, timespan.endTime) + } + + @Test + fun `UpdateEventRequest Timespan with one second duration succeeds`() { + val timespan = UpdateEventRequest.When.Timespan.Builder() + .startTime(1620000000) + .endTime(1620000001) + .build() + + assertEquals(1620000000, timespan.startTime) + assertEquals(1620000001, timespan.endTime) + } + + @Test + fun `CreateEventRequest Timespan with valid duration succeeds`() { + val timespan = CreateEventRequest.When.Timespan.Builder(1620000000, 1620003600) + .build() + + assertEquals(1620000000, timespan.startTime) + assertEquals(1620003600, timespan.endTime) + } + + @Test + fun `CreateEventRequest Timespan with zero duration throws exception`() { + val exception = org.junit.jupiter.api.assertThrows { + CreateEventRequest.When.Timespan.Builder(1620000000, 1620000000) + .build() + } + + assert(exception.message!!.contains("endTime (1620000000) must be after startTime (1620000000)")) + assert(exception.message!!.contains("For point-in-time events, use CreateEventRequest.When.Time instead")) + } + + @Test + fun `CreateEventRequest Timespan with negative duration throws exception`() { + val exception = org.junit.jupiter.api.assertThrows { + CreateEventRequest.When.Timespan.Builder(1620003600, 1620000000) + .build() + } + + assert(exception.message!!.contains("endTime (1620000000) must be after startTime (1620003600)")) + assert(exception.message!!.contains("Timespan events require a positive duration")) + } + + @Test + fun `CreateEventRequest Timespan with one second duration succeeds`() { + val timespan = CreateEventRequest.When.Timespan.Builder(1620000000, 1620000001) + .build() + + assertEquals(1620000000, timespan.startTime) + assertEquals(1620000001, timespan.endTime) + } + + @Test + fun `UpdateEventRequest Timespan validation does not affect direct constructor`() { + // Direct constructor should still work for backward compatibility + val timespan = UpdateEventRequest.When.Timespan(1620000000, 1620000000) + + assertEquals(1620000000, timespan.startTime) + assertEquals(1620000000, timespan.endTime) + } + + @Test + fun `CreateEventRequest Timespan validation does not affect direct constructor`() { + // Direct constructor should still work for backward compatibility + val timespan = CreateEventRequest.When.Timespan(1620000000, 1620000000) + + assertEquals(1620000000, timespan.startTime) + assertEquals(1620000000, timespan.endTime) + } + + @Test + fun `NylasApiError with validation errors formats toString correctly`() { + val error = NylasApiError( + type = "invalid_request", + message = "Event validation failed", + validationErrors = mapOf( + "when.end_time" to "must be after start_time", + "title" to "is required", + ), + statusCode = 400, + requestId = "req_12345", + ) + + val errorString = error.toString() + assert(errorString.contains("Event validation failed")) + assert(errorString.contains("HTTP 400")) + assert(errorString.contains("Validation errors:")) + assert(errorString.contains("when.end_time: must be after start_time")) + assert(errorString.contains("title: is required")) + assert(errorString.contains("Request ID: req_12345")) + } + + @Test + fun `NylasApiError without validation errors formats toString correctly`() { + val error = NylasApiError( + type = "invalid_request", + message = "Bad request", + statusCode = 400, + ) + + val errorString = error.toString() + assert(errorString.contains("Bad request")) + assert(errorString.contains("HTTP 400")) + assert(!errorString.contains("Validation errors:")) + } + + @Test + fun `NylasApiError serializes and deserializes with validation errors`() { + val adapter = JsonHelper.moshi().adapter(NylasApiError::class.java) + val json = """ + { + "type": "invalid_request", + "message": "Validation failed", + "validation_errors": { + "when.end_time": "must be after start_time" + } + } + """.trimIndent() + + val error = adapter.fromJson(json)!! + assertEquals("invalid_request", error.type) + assertEquals("Validation failed", error.message) + assertEquals(mapOf("when.end_time" to "must be after start_time"), error.validationErrors) + } + } + @Nested inner class CrudTests { private lateinit var grantId: String