@onekeyfe/react-native-range-downloader 3.0.67 → 3.0.69
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/android/build.gradle +3 -0
- package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ConcurrentRangeDownloader.kt +179 -11
- package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/RangeDownloadLogic.kt +98 -0
- package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt +24 -25
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/ConcurrentRangeDownloaderOcdsTest.kt +554 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FaultServer.kt +282 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/IsPermanentHttpStatusTest.kt +63 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/MonotonicProgressGateTest.kt +368 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/Ocds416ResumeTest.kt +272 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsBadTotalRejectTest.kt +147 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsMultipartRejectTest.kt +114 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsReadOnlyFsTest.kt +250 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsTransient5xxTest.kt +275 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/RangeDownloadLogicTest.kt +124 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/RunRegistrySingleFlightTest.kt +217 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/SegmentArtifactSweepTest.kt +350 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/SmokeTest.kt +65 -0
- package/ios/RangeDownloadLogic.swift +187 -0
- package/ios/ReactNativeRangeDownloader.swift +669 -133
- package/lib/typescript/src/ReactNativeRangeDownloader.nitro.d.ts +7 -1
- package/lib/typescript/src/ReactNativeRangeDownloader.nitro.d.ts.map +1 -1
- package/nitrogen/generated/android/c++/JHybridReactNativeRangeDownloaderSpec.cpp +4 -0
- package/nitrogen/generated/android/c++/JRangeDownloadOutcome.hpp +6 -0
- package/nitrogen/generated/android/c++/JRangeDownloadParams.hpp +19 -3
- package/nitrogen/generated/android/c++/JRangeDownloadResult.hpp +9 -3
- package/nitrogen/generated/android/c++/JRangeFallbackKind.hpp +83 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadOutcome.kt +3 -1
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadParams.kt +15 -3
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadResult.kt +6 -3
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeFallbackKind.kt +29 -0
- package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Bridge.hpp +18 -0
- package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Umbrella.hpp +3 -0
- package/nitrogen/generated/ios/c++/HybridReactNativeRangeDownloaderSpecSwift.hpp +3 -0
- package/nitrogen/generated/ios/swift/RangeDownloadOutcome.swift +8 -0
- package/nitrogen/generated/ios/swift/RangeDownloadParams.swift +93 -1
- package/nitrogen/generated/ios/swift/RangeDownloadResult.swift +24 -1
- package/nitrogen/generated/ios/swift/RangeFallbackKind.swift +72 -0
- package/nitrogen/generated/shared/c++/RangeDownloadOutcome.hpp +9 -1
- package/nitrogen/generated/shared/c++/RangeDownloadParams.hpp +18 -2
- package/nitrogen/generated/shared/c++/RangeDownloadResult.hpp +9 -2
- package/nitrogen/generated/shared/c++/RangeFallbackKind.hpp +108 -0
- package/package.json +1 -1
- package/src/ReactNativeRangeDownloader.nitro.ts +36 -2
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
package com.margelo.nitro.reactnativerangedownloader
|
|
2
|
+
|
|
3
|
+
import okhttp3.OkHttpClient
|
|
4
|
+
import okhttp3.mockwebserver.Dispatcher
|
|
5
|
+
import okhttp3.mockwebserver.MockResponse
|
|
6
|
+
import okhttp3.mockwebserver.RecordedRequest
|
|
7
|
+
import okhttp3.mockwebserver.SocketPolicy
|
|
8
|
+
import okio.Buffer
|
|
9
|
+
import java.io.File
|
|
10
|
+
import java.security.MessageDigest
|
|
11
|
+
import java.util.Collections
|
|
12
|
+
import java.util.concurrent.ConcurrentHashMap
|
|
13
|
+
import java.util.concurrent.TimeUnit
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Reusable JVM test harness for [ConcurrentRangeDownloader] — the analogue of
|
|
17
|
+
* the desktop e2e "fault server", implemented as an OkHttp [MockWebServer]
|
|
18
|
+
* [Dispatcher] rather than a real HTTP server process.
|
|
19
|
+
*
|
|
20
|
+
* Threading: a custom [Dispatcher.dispatch] is invoked on MockWebServer's
|
|
21
|
+
* internal per-connection reader threads. The core opens up to 8 sockets in
|
|
22
|
+
* parallel (one per pending segment) plus the probe, so [dispatch] is called
|
|
23
|
+
* CONCURRENTLY. Every piece of mutable state here (request log, fault-mode hit
|
|
24
|
+
* counters) is therefore thread-safe (synchronized list / atomic counters).
|
|
25
|
+
* MockWebServer serves each connection on its own thread, so 8 parallel range
|
|
26
|
+
* requests are genuinely concurrent — there is no per-request serialization to
|
|
27
|
+
* mask a concurrency bug in the core.
|
|
28
|
+
*/
|
|
29
|
+
class RangeFaultServer(private val content: ByteArray) {
|
|
30
|
+
|
|
31
|
+
private val total: Long = content.size.toLong()
|
|
32
|
+
private val etag: String = "\"" + sha256(content).substring(0, 16) + "\""
|
|
33
|
+
|
|
34
|
+
/** Every [start,end] (inclusive) the server was asked to serve. Thread-safe. */
|
|
35
|
+
val requestedRanges: MutableList<Pair<Long, Long>> =
|
|
36
|
+
Collections.synchronizedList(ArrayList())
|
|
37
|
+
|
|
38
|
+
/** Count of every request received (incl. probe + retries). Thread-safe. */
|
|
39
|
+
private val faults = ConcurrentHashMap<FaultMode, Fault>()
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A fault that targets exactly one byte range. [times] auto-expires: after it
|
|
43
|
+
* has fired [times] times the matching request succeeds normally, so a
|
|
44
|
+
* scenario can fail one segment N times then let the retry through.
|
|
45
|
+
*/
|
|
46
|
+
private class Fault(
|
|
47
|
+
val targetStart: Long,
|
|
48
|
+
val targetEnd: Long,
|
|
49
|
+
val times: Int,
|
|
50
|
+
) {
|
|
51
|
+
var fired: Int = 0
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
enum class FaultMode {
|
|
55
|
+
/** SocketPolicy.DISCONNECT_DURING_RESPONSE_BODY — drop mid-body. */
|
|
56
|
+
DROP_DURING_BODY,
|
|
57
|
+
|
|
58
|
+
/** Ignore Range, return 200 + full body (core → FALLBACK). */
|
|
59
|
+
STATUS_200,
|
|
60
|
+
|
|
61
|
+
/** 429 with Retry-After: 0, then a normal 206 on the retry. */
|
|
62
|
+
STATUS_429_THEN_OK,
|
|
63
|
+
|
|
64
|
+
/** 5xx transient (retried in place). */
|
|
65
|
+
STATUS_5XX,
|
|
66
|
+
|
|
67
|
+
/** 416 Range Not Satisfiable (transient per OCDS §4). */
|
|
68
|
+
STATUS_416,
|
|
69
|
+
|
|
70
|
+
/** 206 with a Content-Range window that does NOT match the request. */
|
|
71
|
+
MISALIGNED_CONTENT_RANGE,
|
|
72
|
+
|
|
73
|
+
/** 206 with the correct window but a total that DISAGREES with the probe (core → FALLBACK). */
|
|
74
|
+
BAD_TOTAL_206,
|
|
75
|
+
|
|
76
|
+
/** 206 whose body has MORE bytes than the requested range. */
|
|
77
|
+
OVER_LONG_BODY,
|
|
78
|
+
|
|
79
|
+
/** 206 whose body has FEWER bytes than the requested range. */
|
|
80
|
+
SHORT_BODY,
|
|
81
|
+
|
|
82
|
+
/** Content-Type: multipart/byteranges (core → FALLBACK). */
|
|
83
|
+
MULTIPART_BYTERANGES,
|
|
84
|
+
|
|
85
|
+
/** Throttle the body so the read stalls (NO_RESPONSE-like slow trickle). */
|
|
86
|
+
STALL,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Arm a fault on the request whose Range is exactly [start]-[end] (inclusive).
|
|
91
|
+
* For segment-level faults pass the [Part] window from [planParts]. [times]
|
|
92
|
+
* lets the fault auto-expire so the retried request succeeds.
|
|
93
|
+
*/
|
|
94
|
+
fun arm(mode: FaultMode, start: Long, end: Long, times: Int = 1): RangeFaultServer {
|
|
95
|
+
faults[mode] = Fault(start, end, times)
|
|
96
|
+
return this
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Default status code for the [STATUS_5XX] fault (overridable per-arm). */
|
|
100
|
+
var status5xxCode: Int = 503
|
|
101
|
+
|
|
102
|
+
val dispatcher: Dispatcher = object : Dispatcher() {
|
|
103
|
+
override fun dispatch(request: RecordedRequest): MockResponse {
|
|
104
|
+
val range = parseRequestRange(request.getHeader("Range"))
|
|
105
|
+
?: return errorResponse(400) // tests always send a Range
|
|
106
|
+
val (start, end) = range
|
|
107
|
+
requestedRanges.add(start to end)
|
|
108
|
+
|
|
109
|
+
// Find an armed-and-unexpired fault that targets this exact window.
|
|
110
|
+
val firingMode = faults.entries.firstOrNull { (_, f) ->
|
|
111
|
+
f.targetStart == start && f.targetEnd == end && f.fired < f.times
|
|
112
|
+
}?.also { it.value.fired++ }?.key
|
|
113
|
+
|
|
114
|
+
if (firingMode == null) {
|
|
115
|
+
return normal206(start, end)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return when (firingMode) {
|
|
119
|
+
FaultMode.DROP_DURING_BODY ->
|
|
120
|
+
ok206Headers(start, end)
|
|
121
|
+
.setBody(bufferFor(start, end))
|
|
122
|
+
.setSocketPolicy(SocketPolicy.DISCONNECT_DURING_RESPONSE_BODY)
|
|
123
|
+
|
|
124
|
+
FaultMode.STATUS_200 ->
|
|
125
|
+
MockResponse()
|
|
126
|
+
.setResponseCode(200)
|
|
127
|
+
.addHeader("ETag", etag)
|
|
128
|
+
.addHeader("Content-Length", total.toString())
|
|
129
|
+
.setBody(Buffer().write(content))
|
|
130
|
+
|
|
131
|
+
FaultMode.STATUS_429_THEN_OK ->
|
|
132
|
+
MockResponse()
|
|
133
|
+
.setResponseCode(429)
|
|
134
|
+
.addHeader("Retry-After", "0")
|
|
135
|
+
|
|
136
|
+
FaultMode.STATUS_5XX ->
|
|
137
|
+
MockResponse().setResponseCode(status5xxCode)
|
|
138
|
+
|
|
139
|
+
FaultMode.STATUS_416 ->
|
|
140
|
+
MockResponse()
|
|
141
|
+
.setResponseCode(416)
|
|
142
|
+
.addHeader("Content-Range", "bytes */$total")
|
|
143
|
+
|
|
144
|
+
FaultMode.MISALIGNED_CONTENT_RANGE -> {
|
|
145
|
+
// Serve the correct slice but advertise a shifted window.
|
|
146
|
+
val badStart = start + 1
|
|
147
|
+
val badEnd = end + 1
|
|
148
|
+
MockResponse()
|
|
149
|
+
.setResponseCode(206)
|
|
150
|
+
.addHeader("ETag", etag)
|
|
151
|
+
.addHeader("Content-Range", "bytes $badStart-$badEnd/$total")
|
|
152
|
+
.setBody(bufferFor(start, end))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
FaultMode.BAD_TOTAL_206 -> {
|
|
156
|
+
// Serve the correct window + body, but advertise a total that disagrees
|
|
157
|
+
// with the probe total → core must reject (Content-Range total check).
|
|
158
|
+
MockResponse()
|
|
159
|
+
.setResponseCode(206)
|
|
160
|
+
.addHeader("ETag", etag)
|
|
161
|
+
.addHeader("Content-Range", "bytes $start-$end/${total + 1}")
|
|
162
|
+
.setBody(bufferFor(start, end))
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
FaultMode.OVER_LONG_BODY -> {
|
|
166
|
+
// Append one extra byte beyond the requested window.
|
|
167
|
+
val extraEnd = minOf(end + 1, total - 1)
|
|
168
|
+
ok206Headers(start, end).setBody(bufferFor(start, extraEnd))
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
FaultMode.SHORT_BODY -> {
|
|
172
|
+
// One byte fewer than requested (read ends early; segment < length).
|
|
173
|
+
val shortEnd = (end - 1).coerceAtLeast(start)
|
|
174
|
+
ok206Headers(start, end).setBody(bufferFor(start, shortEnd))
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
FaultMode.MULTIPART_BYTERANGES ->
|
|
178
|
+
ok206Headers(start, end)
|
|
179
|
+
.removeHeader("Content-Type")
|
|
180
|
+
.addHeader("Content-Type", "multipart/byteranges; boundary=THISISABOUNDARY")
|
|
181
|
+
.setBody(bufferFor(start, end))
|
|
182
|
+
|
|
183
|
+
FaultMode.STALL ->
|
|
184
|
+
ok206Headers(start, end)
|
|
185
|
+
.setBody(bufferFor(start, end))
|
|
186
|
+
// Trickle 1 byte/sec so the read stalls well past any client read
|
|
187
|
+
// timeout; the socket policy keeps the connection open meanwhile.
|
|
188
|
+
.throttleBody(1, 1, TimeUnit.SECONDS)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private fun normal206(start: Long, end: Long): MockResponse =
|
|
194
|
+
ok206Headers(start, end).setBody(bufferFor(start, end))
|
|
195
|
+
|
|
196
|
+
private fun ok206Headers(start: Long, end: Long): MockResponse =
|
|
197
|
+
MockResponse()
|
|
198
|
+
.setResponseCode(206)
|
|
199
|
+
.addHeader("ETag", etag)
|
|
200
|
+
.addHeader("Content-Type", "application/octet-stream")
|
|
201
|
+
.addHeader("Content-Range", "bytes $start-$end/$total")
|
|
202
|
+
|
|
203
|
+
private fun errorResponse(code: Int): MockResponse =
|
|
204
|
+
MockResponse().setResponseCode(code)
|
|
205
|
+
|
|
206
|
+
/** Inclusive slice [start,end] of [content] as an okio Buffer. */
|
|
207
|
+
private fun bufferFor(start: Long, end: Long): Buffer {
|
|
208
|
+
val from = start.toInt()
|
|
209
|
+
val to = (end + 1).coerceAtMost(total).toInt()
|
|
210
|
+
return Buffer().write(content, from, to - from)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
companion object {
|
|
214
|
+
/** Parse a request `Range: bytes=start-end` header → inclusive (start,end). */
|
|
215
|
+
fun parseRequestRange(header: String?): Pair<Long, Long>? {
|
|
216
|
+
if (header == null) return null
|
|
217
|
+
val m = Regex("""bytes=(\d+)-(\d+)""").find(header) ?: return null
|
|
218
|
+
val start = m.groupValues[1].toLongOrNull() ?: return null
|
|
219
|
+
val end = m.groupValues[2].toLongOrNull() ?: return null
|
|
220
|
+
return start to end
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// Shared helpers (mirror the core so tests assert against the same plan).
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
/** A planned byte range, mirroring [ConcurrentRangeDownloader]'s private Part. */
|
|
230
|
+
data class PlannedPart(val index: Int, val start: Long, val end: Long) {
|
|
231
|
+
val length: Long get() = end - start + 1
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Mirror of the core's `planRanges`: ceil-chunk the total into [segments]
|
|
236
|
+
* inclusive windows. Trailing empty parts are dropped exactly as the core does.
|
|
237
|
+
*/
|
|
238
|
+
fun planParts(total: Long, segments: Int = 8): List<PlannedPart> {
|
|
239
|
+
val parts = ArrayList<PlannedPart>()
|
|
240
|
+
val chunk = (total + segments - 1) / segments
|
|
241
|
+
var i = 0
|
|
242
|
+
while (i < segments) {
|
|
243
|
+
val start = i * chunk
|
|
244
|
+
if (start >= total) break
|
|
245
|
+
val end = minOf(start + chunk - 1, total - 1)
|
|
246
|
+
parts.add(PlannedPart(parts.size, start, end))
|
|
247
|
+
i += 1
|
|
248
|
+
}
|
|
249
|
+
return parts
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** SHA-256 hex digest of a byte array. */
|
|
253
|
+
fun sha256(bytes: ByteArray): String =
|
|
254
|
+
MessageDigest.getInstance("SHA-256").digest(bytes)
|
|
255
|
+
.joinToString("") { "%02x".format(it) }
|
|
256
|
+
|
|
257
|
+
/** SHA-256 hex digest of a file's contents. */
|
|
258
|
+
fun sha256(file: File): String = sha256(file.readBytes())
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Pre-write the correct `.segN` files for parts 0..[completedThrough] (inclusive)
|
|
262
|
+
* so a resume run re-fetches only the tail. Mirrors the core's segment-file
|
|
263
|
+
* resume model: a full-length `.segN` is treated as done and skipped.
|
|
264
|
+
*/
|
|
265
|
+
fun seedSegments(
|
|
266
|
+
partialFilePath: String,
|
|
267
|
+
content: ByteArray,
|
|
268
|
+
completedThrough: Int,
|
|
269
|
+
segments: Int = 8,
|
|
270
|
+
) {
|
|
271
|
+
val parts = planParts(content.size.toLong(), segments)
|
|
272
|
+
for (part in parts) {
|
|
273
|
+
if (part.index > completedThrough) continue
|
|
274
|
+
val segFile = File("$partialFilePath.seg${part.index}")
|
|
275
|
+
segFile.parentFile?.let { if (!it.exists()) it.mkdirs() }
|
|
276
|
+
segFile.writeBytes(content.copyOfRange(part.start.toInt(), (part.end + 1).toInt()))
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** A real [ConcurrentRangeDownloader] over a plain OkHttpClient (no app deps). */
|
|
281
|
+
fun newDownloader(segments: Int = 8): ConcurrentRangeDownloader =
|
|
282
|
+
ConcurrentRangeDownloader(OkHttpClient(), segments)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
package com.margelo.nitro.reactnativerangedownloader
|
|
2
|
+
|
|
3
|
+
import org.junit.Assert.assertFalse
|
|
4
|
+
import org.junit.Assert.assertTrue
|
|
5
|
+
import org.junit.Test
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* OCDS §4 conformance: the HTTP-status → permanent/transient classification.
|
|
9
|
+
* Mirrors the JS taxonomy matrix (updateErrorTaxonomy.test.ts) so the four
|
|
10
|
+
* platform classifiers stay in lockstep. `true` = permanent, `false` = transient.
|
|
11
|
+
*/
|
|
12
|
+
class IsPermanentHttpStatusTest {
|
|
13
|
+
|
|
14
|
+
@Test
|
|
15
|
+
fun permanentStatuses() {
|
|
16
|
+
// auth / expired signed URL / gone / not found
|
|
17
|
+
for (code in listOf(401, 403, 404, 410)) {
|
|
18
|
+
assertTrue("HTTP $code should be permanent", isPermanentHttpStatus(code))
|
|
19
|
+
}
|
|
20
|
+
// not-implemented / version-not-supported are permanent even though 5xx
|
|
21
|
+
assertTrue("HTTP 501 should be permanent", isPermanentHttpStatus(501))
|
|
22
|
+
assertTrue("HTTP 505 should be permanent", isPermanentHttpStatus(505))
|
|
23
|
+
// every other 4xx → permanent (catch-all default)
|
|
24
|
+
for (code in listOf(400, 402, 405, 409, 411, 418, 451, 499)) {
|
|
25
|
+
assertTrue("HTTP $code (other 4xx) should be permanent", isPermanentHttpStatus(code))
|
|
26
|
+
}
|
|
27
|
+
// unknown / non-HTTP → permanent
|
|
28
|
+
for (code in listOf(0, -1, 100, 300, 600, 999)) {
|
|
29
|
+
assertTrue("HTTP $code (unknown) should be permanent", isPermanentHttpStatus(code))
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@Test
|
|
34
|
+
fun transientStatuses() {
|
|
35
|
+
// request timeout / throttling
|
|
36
|
+
assertFalse("HTTP 408 should be transient", isPermanentHttpStatus(408))
|
|
37
|
+
assertFalse("HTTP 429 should be transient", isPermanentHttpStatus(429))
|
|
38
|
+
// server errors except 501/505 → transient (back off and retry)
|
|
39
|
+
for (code in listOf(500, 502, 503, 504, 506, 599)) {
|
|
40
|
+
assertFalse("HTTP $code (5xx) should be transient", isPermanentHttpStatus(code))
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The regression this guards: 416 is a member of `400..499`, so a naive
|
|
46
|
+
* catch-all would mark it permanent and discard resumable `.segN` bytes.
|
|
47
|
+
* It MUST resolve transient; its 4xx neighbours must stay permanent.
|
|
48
|
+
*/
|
|
49
|
+
@Test
|
|
50
|
+
fun rangeNotSatisfiableIsTransientNotPermanent() {
|
|
51
|
+
assertFalse("HTTP 416 must be transient", isPermanentHttpStatus(416))
|
|
52
|
+
assertTrue("HTTP 415 (neighbour) should be permanent", isPermanentHttpStatus(415))
|
|
53
|
+
assertTrue("HTTP 417 (neighbour) should be permanent", isPermanentHttpStatus(417))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Exactly-one-class is total: every status in 100..599 returns a Boolean. */
|
|
57
|
+
@Test
|
|
58
|
+
fun everyStatusResolvesWithoutThrowing() {
|
|
59
|
+
for (code in 100..599) {
|
|
60
|
+
isPermanentHttpStatus(code)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|