From 5fc6207b9a1ddb6f2f52b5ac7cfc21afffaa54a2 Mon Sep 17 00:00:00 2001 From: Maxime MICHEL Date: Wed, 23 Sep 2026 18:29:45 +0200 Subject: [PATCH] :white_check_mark: Close remaining audit coverage gaps (#91) Re-scoped issue #91's gap list against current code before writing anything (the issue predates the OpenAPI migration and named files/classes that no longer exist) and found four of its seven gaps already closed by prior work: - Delay precedence: covered except the "neither declares one -> null" case - extended with one more test (existing test already covers operation-level override and spec-default fallback). - Ambiguous host-match precedence: already fully covered by MockConfigRepositoryTest's two "hosts collide" tests. No action. - Preview/diff bottom sheet: already fully covered by MockResponsePreviewPageTest.kt (the page) and DiffLineUtilsTest.kt (the diff logic it drives). No action. - Response Content-Type/header assertions: already closed by #127 (returnsMockResponse_withDefaultContentTypeHeader / _withDeclaredHeadersAndContentType). No action. Three gaps remained genuinely open: - Real sample spec parsing: no test previously loaded the actual shipped sample specs (sample/network's sample-api.json, jsonplaceholder.json) through the real MockConfigRepository - every existing test uses hand-built inline fixtures, so the shipped sample could silently drift out of sync with what the parser accepts. New RealSampleSpecTest (androidHostTest, since java.io.File isn't available on Kotlin/Native and this is a plain JVM sanity check) reads them directly off disk via a Gradle-configured devview.sampleNetworkResourcesDir system property, computed once at configuration time from rootProject.file(...) so the test doesn't depend on the JVM working directory - deliberately not a compile-time dependency on the sample module, which would invert this module's place in the dependency graph. - Query-parameter matching end-to-end through the Ktor plugin: RequestMatcherTest already covered matchesQueryParams in isolation, but NetworkMockPluginTest had no end-to-end coverage through the actual interception path. KtorPluginTestData's shared spec gains a listUsers operation (?type=user, mirroring the real sample spec's own listUsers) and NetworkMockPluginTest gains a "Query parameter matching" region: matches on the declared value, falls through to network on a different value or a missing param. - Sticky-header status-family grouping: OperationPickerPage's response list already groups by StatusCodeFamily with a sticky header per group, but nothing asserted the header text itself or that responses actually land in distinct groups - NetworkMockOperationSheetTest's existing 200/404 fixture already spans two families, so one added test closes this by asserting both family headers render. CHANGELOG.md documents this as a coverage-closing entry rather than a user-facing change (nothing here alters library behavior). --- CHANGELOG.md | 11 ++ devview-networkmock-core/build.gradle.kts | 8 ++ .../core/repository/RealSampleSpecTest.kt | 118 ++++++++++++++++++ .../repository/MockConfigRepositoryTest.kt | 15 +++ .../ktor/fixtures/KtorPluginTestData.kt | 20 ++- .../ktor/plugin/NetworkMockPluginTest.kt | 69 ++++++++++ .../NetworkMockOperationSheetTest.kt | 10 ++ 7 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 devview-networkmock-core/src/androidHostTest/kotlin/com/worldline/devview/networkmock/core/repository/RealSampleSpecTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ddda66c..757a79df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- NetworkMock: closed the remaining test-coverage gaps tracked in #91 — real sample specs + (`sample/network`'s `sample-api.json` and `jsonplaceholder.json`) now parse through the actual + `MockConfigRepository` in a new `RealSampleSpecTest` (`devview-networkmock-core`, + `androidHostTest`), guarding against the shipped sample silently drifting out of sync with what + the parser accepts; query-parameter matching is now exercised end-to-end through the real Ktor + plugin interception path (`NetworkMockPluginTest`); the operation sheet's sticky-header + status-family grouping now has explicit coverage (`NetworkMockOperationSheetTest`); and the + delay-precedence chain (`Operation.delayMs ?: ApiSpec.delayMs ?: null`) now covers its + previously-untested third case. Ambiguous host-match precedence, the preview/diff bottom sheet, + and response Content-Type/header assertions were already covered by prior work — verified, not + duplicated. - NetworkMock: operations can now declare OpenAPI `tags`, read verbatim into a new `Operation.tags: List` (empty by default, display-only — no effect on request matching, same as `Operation.version`). The operation list gains a fourth per-tab filter chip diff --git a/devview-networkmock-core/build.gradle.kts b/devview-networkmock-core/build.gradle.kts index 46ca3c00..4c771a52 100644 --- a/devview-networkmock-core/build.gradle.kts +++ b/devview-networkmock-core/build.gradle.kts @@ -38,4 +38,12 @@ kotlin { tasks.withType { failOnNoDiscoveredTests.set(false) + // Points RealSampleSpecTest (androidHostTest) at the sample app's real, shipped OpenAPI + // specs/response files without a compile-time dependency on the sample module - this is a + // pure file-system read at test-run-time, guarding against the shipped sample silently + // drifting out of sync with what this parser actually accepts. + systemProperty( + "devview.sampleNetworkResourcesDir", + rootProject.file("sample/network/src/commonMain/composeResources").absolutePath + ) } diff --git a/devview-networkmock-core/src/androidHostTest/kotlin/com/worldline/devview/networkmock/core/repository/RealSampleSpecTest.kt b/devview-networkmock-core/src/androidHostTest/kotlin/com/worldline/devview/networkmock/core/repository/RealSampleSpecTest.kt new file mode 100644 index 00000000..81364e67 --- /dev/null +++ b/devview-networkmock-core/src/androidHostTest/kotlin/com/worldline/devview/networkmock/core/repository/RealSampleSpecTest.kt @@ -0,0 +1,118 @@ +package com.worldline.devview.networkmock.core.repository + +import com.worldline.devview.networkmock.core.model.OperationKey +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import java.io.File +import kotlin.test.Test +import kotlinx.coroutines.test.runTest + +/** + * Loads the real, shipped sample specs (`sample/network`'s `composeResources/files/networkmocks/`) + * through [MockConfigRepository] itself — every other test in this module uses hand-built inline + * JSON/YAML fixtures, so nothing previously guarded the actual sample app against silently + * drifting out of sync with what this parser accepts (see #91). + * + * Reads the sample's spec/response files directly from disk via [File] rather than depending on + * the `sample:network` module at compile time (which would invert this module's place in the + * dependency graph — `sample` depends on the `networkmock` family, not the reverse). The absolute + * path is supplied by `devview-networkmock-core/build.gradle.kts` as the + * `devview.sampleNetworkResourcesDir` system property, computed once at Gradle configuration time + * so this test doesn't depend on the JVM working directory at run time. + * + * `androidHostTest`-only (not `commonTest`): `java.io.File` isn't available on Kotlin/Native, and + * this is a plain JVM sanity check — it doesn't need multiplatform coverage. + */ +class RealSampleSpecTest { + + @Test + fun `sample-api spec parses successfully through the real repository`() = runTest { + val repository = repositoryFor(specPath = "files/networkmocks/specs/sample-api.json") + + val config = repository.loadConfiguration().getOrThrow() + + val spec = config.specs.single() + spec.id shouldBe "sample-api" + spec.operations.map { it.operationId } shouldContainExactlyInAnyOrder listOf( + "getUserProfile", + "getUserProfileV2", + "updateProfile" + ) + } + + @Test + fun `sample-api's declared response files all load successfully`() = runTest { + val repository = repositoryFor(specPath = "files/networkmocks/specs/sample-api.json") + + val responses = repository.discoverResponseFiles( + key = OperationKey(specId = "sample-api", operationId = "getUserProfile") + ) + + responses.map { it.statusCode }.sorted() shouldBe listOf(200, 401, 404) + } + + @Test + fun `jsonplaceholder spec parses successfully through the real repository`() = runTest { + val repository = repositoryFor(specPath = "files/networkmocks/specs/jsonplaceholder.json") + + val config = repository.loadConfiguration().getOrThrow() + + val spec = config.specs.single() + spec.id shouldBe "jsonplaceholder" + spec.operations.map { it.operationId } shouldContainExactlyInAnyOrder listOf( + "getUser", + "listUsers", + "createPost", + "getPost", + "updateUser", + "deleteUser", + "createUser", + "listPosts", + "updatePost", + "deletePost", + "getPostComments", + "listComments", + "createComment", + "getComment", + "listAlbums", + "createAlbum", + "getAlbum", + "getAlbumPhotos", + "listPhotos", + "getPhoto", + "listTodos", + "createTodo", + "getTodo", + "updateTodo" + ) + } + + @Test + fun `jsonplaceholder's declared response files all load successfully`() = runTest { + val repository = repositoryFor(specPath = "files/networkmocks/specs/jsonplaceholder.json") + + val responses = repository.discoverResponseFiles( + key = OperationKey(specId = "jsonplaceholder", operationId = "getUser") + ) + + // 200 (1 example) + 404 (2 examples: default, detailed) + 500 (1 example) = 4. + responses shouldHaveSize 4 + responses.map { it.statusCode }.sorted() shouldBe listOf(200, 404, 404, 500) + } + + private fun repositoryFor(specPath: String): MockConfigRepository = MockConfigRepository( + specPaths = listOf(specPath), + resourceLoader = { path -> readSampleResource(path = path) } + ) + + private fun readSampleResource(path: String): ByteArray { + val resourcesRoot = System.getProperty("devview.sampleNetworkResourcesDir") + ?: error( + message = "devview.sampleNetworkResourcesDir system property is not set - " + + "check devview-networkmock-core/build.gradle.kts's tasks.withType block." + ) + return File(resourcesRoot, path).readBytes() + } +} + diff --git a/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepositoryTest.kt b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepositoryTest.kt index 3918ac7d..0a8f219f 100644 --- a/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepositoryTest.kt +++ b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepositoryTest.kt @@ -593,6 +593,21 @@ class MockConfigRepositoryTest { withoutOwnDelay?.delayMs shouldBe 200 } + @Test + fun `findMatchingMock delayMs is null when neither operation nor spec declares one`() = runTest { + // baseSpecJson declares no x-devview at any level - completes the precedence chain + // (operation override, spec default) the test above covers with the "no delay at all" case. + val repository = createRepository(resources = baseResources()) + + val match = repository.findMatchingMock( + host = "api.example.com", + path = "/api/users/42", + method = "GET" + ) + + match?.delayMs.shouldBeNull() + } + @Test fun `x-devview failureRate is parsed as an operation-level field with no spec-wide default`() = runTest { diff --git a/devview-networkmock-ktor/src/androidHostTest/kotlin/com/worldline/devview/networkmock/ktor/fixtures/KtorPluginTestData.kt b/devview-networkmock-ktor/src/androidHostTest/kotlin/com/worldline/devview/networkmock/ktor/fixtures/KtorPluginTestData.kt index c263d58f..2345bd1d 100644 --- a/devview-networkmock-ktor/src/androidHostTest/kotlin/com/worldline/devview/networkmock/ktor/fixtures/KtorPluginTestData.kt +++ b/devview-networkmock-ktor/src/androidHostTest/kotlin/com/worldline/devview/networkmock/ktor/fixtures/KtorPluginTestData.kt @@ -41,6 +41,23 @@ internal object KtorPluginTestData { } }, "/api/users": { + "get": { + "operationId": "listUsers", + "parameters": [ + { "name": "type", "in": "query", "example": "user" } + ], + "responses": { + "200": { + "content": { + "application/json": { + "examples": { + "default": { "externalValue": "/files/networkmocks/responses/listUsers-200.json" } + } + } + } + } + } + }, "post": { "operationId": "createUser", "responses": { @@ -82,7 +99,8 @@ internal object KtorPluginTestData { "files/networkmocks/responses/getUser-200.json" to """{"id":1,"name":"Alice"}""", "files/networkmocks/responses/getUser-404.json" to """{"error":"not found"}""", "files/networkmocks/responses/createUser-201.json" to """{"id":2}""", - "files/networkmocks/responses/getProduct-200.json" to """{"id":10,"name":"Widget"}""" + "files/networkmocks/responses/getProduct-200.json" to """{"id":10,"name":"Widget"}""", + "files/networkmocks/responses/listUsers-200.json" to """[{"id":1,"name":"Alice"}]""" ) /** Resource loader backed by the in-memory map above. */ diff --git a/devview-networkmock-ktor/src/androidHostTest/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockPluginTest.kt b/devview-networkmock-ktor/src/androidHostTest/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockPluginTest.kt index 313b6285..b9cd7db8 100644 --- a/devview-networkmock-ktor/src/androidHostTest/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockPluginTest.kt +++ b/devview-networkmock-ktor/src/androidHostTest/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockPluginTest.kt @@ -741,6 +741,75 @@ class NetworkMockPluginTest { // endregion + // region Query parameter matching + + @Test + fun queryParameterMatching_matchesWhenDeclaredQueryParamValueIsPresent() = runTest { + val state = NetworkMockState( + globalMockingEnabled = true, + operationStates = mapOf( + "example-listUsers" to OperationMockState.Mock(statusCode = 200, exampleName = "default") + ) + ) + val client = buildClient( + engine = networkEngine(body = """{"source":"network"}"""), + configRepository = configRepository(), + stateRepository = stateRepositoryMock(state = state) + ) + + val response: HttpResponse = client.get( + urlString = "https://staging.api.example.com/api/users?type=user" + ) + + response.status shouldBe HttpStatusCode.OK + response.body() shouldBe """[{"id":1,"name":"Alice"}]""" + } + + @Test + fun queryParameterMatching_fallsThroughToNetwork_whenDeclaredQueryParamValueDiffers() = runTest { + val state = NetworkMockState( + globalMockingEnabled = true, + operationStates = mapOf( + "example-listUsers" to OperationMockState.Mock(statusCode = 200, exampleName = "default") + ) + ) + val client = buildClient( + engine = networkEngine(body = """{"source":"network"}"""), + configRepository = configRepository(), + stateRepository = stateRepositoryMock(state = state) + ) + + // listUsers only declares a match for ?type=user - a different value doesn't match. + val response: HttpResponse = client.get( + urlString = "https://staging.api.example.com/api/users?type=admin" + ) + + response.body() shouldBe """{"source":"network"}""" + } + + @Test + fun queryParameterMatching_fallsThroughToNetwork_whenDeclaredQueryParamIsMissing() = runTest { + val state = NetworkMockState( + globalMockingEnabled = true, + operationStates = mapOf( + "example-listUsers" to OperationMockState.Mock(statusCode = 200, exampleName = "default") + ) + ) + val client = buildClient( + engine = networkEngine(body = """{"source":"network"}"""), + configRepository = configRepository(), + stateRepository = stateRepositoryMock(state = state) + ) + + val response: HttpResponse = client.get( + urlString = "https://staging.api.example.com/api/users" + ) + + response.body() shouldBe """{"source":"network"}""" + } + + // endregion + // region Error / fallback behaviour @Test diff --git a/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/NetworkMockOperationSheetTest.kt b/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/NetworkMockOperationSheetTest.kt index b5e8f8f5..933a2c82 100644 --- a/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/NetworkMockOperationSheetTest.kt +++ b/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/NetworkMockOperationSheetTest.kt @@ -50,6 +50,16 @@ class NetworkMockOperationSheetTest { onNodeWithTag(testTag = "mock_item_404_default").assertIsDisplayed() } + @Test + fun groupsResponsesByStatusFamily_showingAStickyHeaderPerFamily() = runComposeUiTest { + // response200 (2xx) and response404 (4xx) fall into distinct StatusCodeFamily groups - + // each must get its own sticky header, not be lumped under one. + setPickerPage(currentState = OperationMockState.Network) + + onNodeWithText(text = "SUCCESSFUL MOCKS").assertIsDisplayed() + onNodeWithText(text = "CLIENT ERROR MOCKS").assertIsDisplayed() + } + @Test fun tappingNetworkItem_selectsNetwork() = runComposeUiTest { var selected: MockResponse? = response200