Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>` (empty by default, display-only — no effect on request
matching, same as `Operation.version`). The operation list gains a fourth per-tab filter chip
Expand Down
8 changes: 8 additions & 0 deletions devview-networkmock-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,12 @@ kotlin {

tasks.withType<Test> {
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
)
}
Original file line number Diff line number Diff line change
@@ -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<Test> block."
)
return File(resourcesRoot, path).readBytes()
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>() 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<String>() 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<String>() shouldBe """{"source":"network"}"""
}

// endregion

// region Error / fallback behaviour

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading