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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- NetworkMock: an operation can now be configured as a **sequence** — an ordered list of
responses it advances through one step per matched request, sticking on the last step once
exhausted rather than looping back to the start. Useful for polling flows (order status,
upload progress, async job completion) where the interesting behavior is the transition
across repeated calls. Build one from the operation sheet's new "SEQUENCE" section: tap
"Build a Sequence", tap responses in the desired order, then "Save Sequence"; "Reset
Position" restarts at step 1 without leaving the sequence. `OperationMockState` gains a
`Sequence(responses: List<Mock>, currentIndex: Int)` variant (**breaking**: another new
sealed subtype); the position is persisted as part of this same state, so resetting an
operation to `Network` discards it with no special-casing needed.
`NetworkMockViewModel` gains `setOperationSequenceState`/`resetOperationSequencePosition`.
(`devview-networkmock-core`, `devview-networkmock-ktor`, `devview-networkmock`, #96)
- NetworkMock: an operation can now simulate a network failure instead of returning a response,
two ways — **deterministically**, by selecting Timeout or Connection Refused in the operation
sheet's picker page (a new "Simulate Failure" section, alongside the response variants); or
Expand Down
15 changes: 15 additions & 0 deletions devview-networkmock-core/api/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,21 @@ package com.worldline.devview.networkmock.core.model {
field public static final com.worldline.devview.networkmock.core.model.OperationMockState.Network INSTANCE;
}

@androidx.compose.runtime.Immutable @kotlinx.serialization.SerialName("sequence") @kotlinx.serialization.Serializable public static final class OperationMockState.Sequence implements com.worldline.devview.networkmock.core.model.OperationMockState {
ctor public OperationMockState.Sequence(java.util.List<com.worldline.devview.networkmock.core.model.OperationMockState.Mock> responses, optional int currentIndex);
method public java.util.List<com.worldline.devview.networkmock.core.model.OperationMockState.Mock> component1();
method public int component2();
method public com.worldline.devview.networkmock.core.model.OperationMockState.Sequence copy(optional java.util.List<com.worldline.devview.networkmock.core.model.OperationMockState.Mock> responses, optional int currentIndex);
method @InaccessibleFromKotlin public int getCurrentIndex();
method @InaccessibleFromKotlin public com.worldline.devview.networkmock.core.model.OperationMockState.Mock? getCurrentResponse();
method @InaccessibleFromKotlin public String getDisplayName();
method @InaccessibleFromKotlin public java.util.List<com.worldline.devview.networkmock.core.model.OperationMockState.Mock> getResponses();
property public int currentIndex;
property public com.worldline.devview.networkmock.core.model.OperationMockState.Mock? currentResponse;
property public String displayName;
property public java.util.List<com.worldline.devview.networkmock.core.model.OperationMockState.Mock> responses;
}

public enum StatusCodeFamily {
method @InaccessibleFromKotlin public String getDisplayName();
property public String displayName;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ public data class NetworkMockState(
* | [Network] | All requests pass through to the actual network (default) | `"Network"` |
* | [Mock] | Requests return the selected response variant | `"$statusCode - $exampleName"` |
* | [Failure] | Requests fail with the selected [FailureKind] | e.g. `"Timeout"` |
* | [Sequence] | Requests advance through an ordered list, sticking on the last | e.g. `"200 - default (2/3)"` |
*
* @see NetworkMockState
*/
Expand Down Expand Up @@ -157,6 +158,36 @@ public sealed interface OperationMockState {
public data class Failure(val kind: FailureKind) : OperationMockState {
override val displayName: String get() = kind.displayName
}

/**
* The operation advances through [responses] in order on each successive matched request,
* sticking on the last response once exhausted rather than looping back to the start or
* falling through to the real network — the least surprising default, and the one that
* matches how a real async-completion flow behaves (the terminal state is stable).
*
* [currentIndex] is persisted as part of this same state — no separate counter — so a plain
* [NetworkMockState.resetAllToNetwork]/reset-to-`Network` already discards the position
* along with everything else about the sequence, with no special-casing needed.
*
* @property responses The ordered steps, at least one. Two-or-more is the useful case; a
* single-element sequence is just [Mock] with extra ceremony.
* @property currentIndex Which step serves next, clamped to `responses.indices` by whatever
* advances it — see `NetworkMockPlugin` in `devview-networkmock-ktor`.
*/
@Immutable
@Serializable
@SerialName("sequence")
public data class Sequence(val responses: List<Mock>, val currentIndex: Int = 0) :
OperationMockState {
/** The step that serves next, or `null` if [responses] is empty. */
public val currentResponse: Mock? get() = responses.getOrNull(index = currentIndex)

override val displayName: String
get() {
val current = currentResponse ?: return "Sequence"
return "${current.displayName} (${currentIndex + 1}/${responses.size})"
}
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,43 @@ class MockStateRepositoryTest {
operationState.kind shouldBe FailureKind.TIMEOUT
}

@Test
fun `setOperationMockState persists sequence state with its position for an operation`() = runTest {
val repository = createRepository()
val steps = listOf(
OperationMockState.Mock(statusCode = 202, exampleName = "pending"),
OperationMockState.Mock(statusCode = 200, exampleName = "default")
)

repository.setOperationMockState(
key = key(operationId = "getUser"),
state = OperationMockState.Sequence(responses = steps, currentIndex = 1)
)

val operationState = repository.getState().getOperationState(key = key(operationId = "getUser"))
operationState.shouldBeInstanceOf<OperationMockState.Sequence>()
operationState.responses shouldBe steps
operationState.currentIndex shouldBe 1
}

@Test
fun `setOperationMockState persists network state and discards a previous sequence position`() = runTest {
val repository = createRepository()
val steps = listOf(
OperationMockState.Mock(statusCode = 202, exampleName = "pending"),
OperationMockState.Mock(statusCode = 200, exampleName = "default")
)
repository.setOperationMockState(
key = key(operationId = "getUser"),
state = OperationMockState.Sequence(responses = steps, currentIndex = 1)
)

repository.setOperationMockState(key = key(operationId = "getUser"), state = OperationMockState.Network)

val operationState = repository.getState().getOperationState(key = key(operationId = "getUser"))
operationState shouldBe OperationMockState.Network
}

@Test
fun `setOperationMockState is reflected in observeState`() = runTest {
val repository = createRepository()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.worldline.devview.networkmock.ktor.plugin

import com.worldline.devview.networkmock.core.model.FailureKind
import com.worldline.devview.networkmock.core.model.NetworkMockState
import com.worldline.devview.networkmock.core.model.OperationKey
import com.worldline.devview.networkmock.core.model.OperationMockState
import com.worldline.devview.networkmock.core.repository.MockConfigRepository
import com.worldline.devview.networkmock.core.repository.MockStateRepository
Expand All @@ -25,6 +26,7 @@ import io.mockk.mockk
import kotlin.random.Random
import kotlin.test.Test
import kotlin.test.assertFailsWith
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import kotlinx.io.IOException
Expand Down Expand Up @@ -221,6 +223,83 @@ class NetworkMockPluginTest {

// endregion

// region Sequential mocks

@Test
fun sequence_advancesThroughStepsOnSuccessiveRequests_andSticksOnLastOnceExhausted() = runTest {
val steps = listOf(
OperationMockState.Mock(statusCode = 200, exampleName = "default"),
OperationMockState.Mock(statusCode = 404, exampleName = "default")
)
val stateRepository = mutableStateRepositoryMock(
initial = NetworkMockState(
globalMockingEnabled = true,
operationStates = mapOf(
"example-getUser" to OperationMockState.Sequence(responses = steps)
)
)
)
val client = buildClient(
engine = networkEngine(),
configRepository = configRepository(),
stateRepository = stateRepository
)

// Step 1: the first response, then advances to index 1.
client.get(urlString = "https://staging.api.example.com/api/users/42")
.status shouldBe HttpStatusCode.OK
// Step 2: the second (last) response, then sticks at index 1 - no third step exists.
client.get(urlString = "https://staging.api.example.com/api/users/42")
.status shouldBe HttpStatusCode.NotFound
// Step 3 onward: still the last response.
client.get(urlString = "https://staging.api.example.com/api/users/42")
.status shouldBe HttpStatusCode.NotFound
}

@Test
fun probabilisticFailure_appliesToSequenceStatesToo() = runTest {
val resources = flakySpecResources(failureRate = 1.0)
val stateRepository = mutableStateRepositoryMock(
initial = NetworkMockState(
globalMockingEnabled = true,
operationStates = mapOf(
"example-getUser" to OperationMockState.Sequence(
responses = listOf(OperationMockState.Mock(statusCode = 200, exampleName = "default"))
)
)
)
)
val client = buildClient(
engine = networkEngine(),
configRepository = configRepository(resources = resources),
stateRepository = stateRepository,
random = FixedRandom(value = 0.0)
)

assertFailsWith<IOException> {
client.get(urlString = "https://staging.api.example.com/api/users/42")
}
}

/**
* Unlike [stateRepositoryMock], writes actually mutate the backing state, so a test can
* make more than one request and observe the plugin's own advance-and-persist write.
*/
private fun mutableStateRepositoryMock(initial: NetworkMockState): MockStateRepository {
val stateFlow = MutableStateFlow(initial)
return mockk<MockStateRepository>(relaxed = true) {
coEvery { getState() } answers { stateFlow.value }
every { observeState() } returns stateFlow
coEvery { setOperationMockState(key = any(), state = any()) } answers {
val key = firstArg<OperationKey>()
val newState = secondArg<OperationMockState>()
stateFlow.value = stateFlow.value.withOperationState(key = key, state = newState)
}
}
}

// endregion

// region Failure simulation

@Test
Expand Down
Loading
Loading