clean
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
package com.rssuper.services
|
||||
|
||||
import com.rssuper.parsing.FeedParser
|
||||
import com.rssuper.parsing.ParseResult
|
||||
import okhttp3.Call
|
||||
import okhttp3.EventListener
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class FeedFetcher(
|
||||
private val timeoutMs: Long = 15000,
|
||||
private val maxRetries: Int = 3,
|
||||
private val baseRetryDelayMs: Long = 1000
|
||||
) {
|
||||
private val client: OkHttpClient
|
||||
|
||||
init {
|
||||
val builder = OkHttpClient.Builder()
|
||||
.connectTimeout(timeoutMs, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(timeoutMs, TimeUnit.MILLISECONDS)
|
||||
.writeTimeout(timeoutMs, TimeUnit.MILLISECONDS)
|
||||
|
||||
builder.eventListenerFactory { call -> TimeoutEventListener(call) }
|
||||
|
||||
client = builder.build()
|
||||
}
|
||||
|
||||
fun fetch(
|
||||
url: String,
|
||||
httpAuth: HTTPAuthCredentials? = null,
|
||||
ifNoneMatch: String? = null,
|
||||
ifModifiedSince: String? = null
|
||||
): NetworkResult<FetchResult> {
|
||||
var lastError: Throwable? = null
|
||||
|
||||
for (attempt in 1..maxRetries) {
|
||||
val result = fetchSingleAttempt(url, httpAuth, ifNoneMatch, ifModifiedSince)
|
||||
|
||||
when (result) {
|
||||
is NetworkResult.Success -> return result
|
||||
is NetworkResult.Failure -> {
|
||||
lastError = result.error
|
||||
if (attempt < maxRetries) {
|
||||
val delay = calculateBackoffDelay(attempt)
|
||||
Thread.sleep(delay)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NetworkResult.Failure(lastError ?: NetworkError.Unknown())
|
||||
}
|
||||
|
||||
fun fetchAndParse(url: String, httpAuth: HTTPAuthCredentials? = null): NetworkResult<ParseResult> {
|
||||
val fetchResult = fetch(url, httpAuth)
|
||||
|
||||
return fetchResult.flatMap { result ->
|
||||
try {
|
||||
val parseResult = FeedParser.parse(result.feedXml, url)
|
||||
NetworkResult.Success(parseResult)
|
||||
} catch (e: Exception) {
|
||||
NetworkResult.Failure(NetworkError.Unknown(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchSingleAttempt(
|
||||
url: String,
|
||||
httpAuth: HTTPAuthCredentials? = null,
|
||||
ifNoneMatch: String? = null,
|
||||
ifModifiedSince: String? = null
|
||||
): NetworkResult<FetchResult> {
|
||||
val requestBuilder = Request.Builder()
|
||||
.url(url)
|
||||
.addHeader("User-Agent", "RSSuper/1.0")
|
||||
|
||||
ifNoneMatch?.let { requestBuilder.addHeader("If-None-Match", it) }
|
||||
ifModifiedSince?.let { requestBuilder.addHeader("If-Modified-Since", it) }
|
||||
|
||||
httpAuth?.let {
|
||||
requestBuilder.addHeader("Authorization", it.toCredentials())
|
||||
}
|
||||
|
||||
val request = requestBuilder.build()
|
||||
|
||||
return try {
|
||||
val response = client.newCall(request).execute()
|
||||
handleResponse(response, url)
|
||||
} catch (e: IOException) {
|
||||
NetworkResult.Failure(NetworkError.Unknown(e))
|
||||
} catch (e: Exception) {
|
||||
NetworkResult.Failure(NetworkError.Unknown(e))
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleResponse(response: Response, url: String): NetworkResult<FetchResult> {
|
||||
try {
|
||||
val body = response.body
|
||||
|
||||
return when (response.code) {
|
||||
200 -> {
|
||||
if (body != null) {
|
||||
NetworkResult.Success(FetchResult.fromResponse(response, url, response.cacheResponse != null))
|
||||
} else {
|
||||
NetworkResult.Failure(NetworkError.Http(response.code, "Empty response body"))
|
||||
}
|
||||
}
|
||||
304 -> {
|
||||
if (body != null) {
|
||||
NetworkResult.Success(FetchResult.fromResponse(response, url, true))
|
||||
} else {
|
||||
NetworkResult.Failure(NetworkError.Http(response.code, "Empty response body"))
|
||||
}
|
||||
}
|
||||
in 400..499 -> {
|
||||
NetworkResult.Failure(NetworkError.Http(response.code, "Client error: ${response.message}"))
|
||||
}
|
||||
in 500..599 -> {
|
||||
NetworkResult.Failure(NetworkError.Http(response.code, "Server error: ${response.message}"))
|
||||
}
|
||||
else -> {
|
||||
NetworkResult.Failure(NetworkError.Http(response.code, "Unexpected status code: ${response.code}"))
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
response.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateBackoffDelay(attempt: Int): Long {
|
||||
var delay = baseRetryDelayMs
|
||||
for (i in 1 until attempt) {
|
||||
delay *= 2
|
||||
}
|
||||
return delay
|
||||
}
|
||||
|
||||
private class TimeoutEventListener(private val call: Call) : EventListener() {
|
||||
override fun callStart(call: Call) {
|
||||
}
|
||||
|
||||
override fun callEnd(call: Call) {
|
||||
}
|
||||
|
||||
override fun callFailed(call: Call, ioe: IOException) {
|
||||
}
|
||||
}
|
||||
|
||||
sealed class NetworkResult<out T> {
|
||||
data class Success<T>(val value: T) : NetworkResult<T>()
|
||||
data class Failure<T>(val error: Throwable) : NetworkResult<T>()
|
||||
|
||||
fun isSuccess(): Boolean = this is Success
|
||||
fun isFailure(): Boolean = this is Failure
|
||||
|
||||
fun getOrNull(): T? = when (this) {
|
||||
is Success -> value
|
||||
is Failure -> null
|
||||
}
|
||||
|
||||
fun <R> map(transform: (T) -> R): NetworkResult<R> = when (this) {
|
||||
is Success -> Success(transform(value))
|
||||
is Failure -> Failure(error)
|
||||
}
|
||||
|
||||
fun <R> flatMap(transform: (T) -> NetworkResult<R>): NetworkResult<R> = when (this) {
|
||||
is Success -> transform(value)
|
||||
is Failure -> Failure(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.rssuper.services
|
||||
|
||||
import okhttp3.CacheControl
|
||||
import okhttp3.Response
|
||||
|
||||
data class FetchResult(
|
||||
val feedXml: String,
|
||||
val url: String,
|
||||
val cacheControl: CacheControl?,
|
||||
val isCached: Boolean,
|
||||
val etag: String? = null,
|
||||
val lastModified: String? = null
|
||||
) {
|
||||
companion object {
|
||||
fun fromResponse(response: Response, url: String, isCached: Boolean = false): FetchResult {
|
||||
val body = response.body?.string() ?: ""
|
||||
val cacheControl = response.cacheControl
|
||||
val etag = response.header("ETag")
|
||||
val lastModified = response.header("Last-Modified")
|
||||
|
||||
return FetchResult(
|
||||
feedXml = body,
|
||||
url = url,
|
||||
cacheControl = cacheControl,
|
||||
isCached = isCached,
|
||||
etag = etag,
|
||||
lastModified = lastModified
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.rssuper.services
|
||||
|
||||
import okhttp3.Credentials
|
||||
|
||||
data class HTTPAuthCredentials(
|
||||
val username: String,
|
||||
val password: String
|
||||
) {
|
||||
fun toCredentials(): String {
|
||||
return Credentials.basic(username, password)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.rssuper.services
|
||||
|
||||
sealed class NetworkError(message: String? = null, cause: Throwable? = null) : Exception(message, cause) {
|
||||
data class Http(val statusCode: Int, override val message: String) : NetworkError(message)
|
||||
data class Timeout(val durationMs: Long) : NetworkError("Timeout")
|
||||
data class Unknown(override val cause: Throwable? = null) : NetworkError(cause = cause)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.rssuper.services
|
||||
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class FeedFetcherIntegrationTest {
|
||||
|
||||
@Test
|
||||
fun testFetchRealFeed() {
|
||||
val feedFetcher = FeedFetcher(timeoutMs = 15000)
|
||||
|
||||
val result = feedFetcher.fetch("https://example.com/feed.xml")
|
||||
|
||||
assertTrue(result.isSuccess() || result.isFailure())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFetchAndParseRealFeed() {
|
||||
val feedFetcher = FeedFetcher(timeoutMs = 15000)
|
||||
|
||||
val result = feedFetcher.fetchAndParse("https://example.com/feed.xml")
|
||||
|
||||
assertTrue(result.isSuccess() || result.isFailure())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFetchWithHTTPAuthCredentials() {
|
||||
val feedFetcher = FeedFetcher(timeoutMs = 15000)
|
||||
|
||||
val auth = HTTPAuthCredentials("testuser", "testpass")
|
||||
val credentials = auth.toCredentials()
|
||||
|
||||
assertTrue(credentials.startsWith("Basic "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFetchWithCacheControl() {
|
||||
val feedFetcher = FeedFetcher(timeoutMs = 15000)
|
||||
|
||||
val result = feedFetcher.fetch("https://example.com/feed.xml")
|
||||
|
||||
assertTrue(result.isSuccess() || result.isFailure())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFetchPerformance() {
|
||||
val feedFetcher = FeedFetcher(timeoutMs = 15000)
|
||||
|
||||
val startTime = System.currentTimeMillis()
|
||||
val result = feedFetcher.fetch("https://example.com/feed.xml")
|
||||
val duration = System.currentTimeMillis() - startTime
|
||||
|
||||
assertTrue(duration < 20000 || result.isFailure())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFetchWithIfNoneMatch() {
|
||||
val feedFetcher = FeedFetcher(timeoutMs = 15000)
|
||||
|
||||
val etag = "test-etag-value"
|
||||
val result = feedFetcher.fetch("https://example.com/feed.xml", ifNoneMatch = etag)
|
||||
|
||||
assertTrue(result.isSuccess() || result.isFailure())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFetchWithIfModifiedSince() {
|
||||
val feedFetcher = FeedFetcher(timeoutMs = 15000)
|
||||
|
||||
val lastModified = "Mon, 01 Jan 2024 00:00:00 GMT"
|
||||
val result = feedFetcher.fetch("https://example.com/feed.xml", ifModifiedSince = lastModified)
|
||||
|
||||
assertTrue(result.isSuccess() || result.isFailure())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFetchMultipleFeeds() {
|
||||
val feedFetcher = FeedFetcher(timeoutMs = 15000)
|
||||
|
||||
val urls = listOf(
|
||||
"https://example.com/feed1.xml",
|
||||
"https://example.com/feed2.xml"
|
||||
)
|
||||
|
||||
for (url in urls) {
|
||||
val result = feedFetcher.fetch(url)
|
||||
assertTrue(result.isSuccess() || result.isFailure())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFetchWithDifferentTimeouts() {
|
||||
val shortTimeoutFetcher = FeedFetcher(timeoutMs = 1000)
|
||||
val longTimeoutFetcher = FeedFetcher(timeoutMs = 30000)
|
||||
|
||||
val shortClientField = FeedFetcher::class.java.getDeclaredField("client")
|
||||
shortClientField.isAccessible = true
|
||||
val shortClient = shortClientField.get(shortTimeoutFetcher) as okhttp3.OkHttpClient
|
||||
|
||||
val longClientField = FeedFetcher::class.java.getDeclaredField("client")
|
||||
longClientField.isAccessible = true
|
||||
val longClient = longClientField.get(longTimeoutFetcher) as okhttp3.OkHttpClient
|
||||
|
||||
assertTrue(shortClient.connectTimeoutMillis < longClient.connectTimeoutMillis)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.rssuper.services
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class FeedFetcherTest {
|
||||
|
||||
@Test
|
||||
fun testOkHttpConfiguration() {
|
||||
val feedFetcher = FeedFetcher(timeoutMs = 5000)
|
||||
val clientField = FeedFetcher::class.java.getDeclaredField("client")
|
||||
clientField.isAccessible = true
|
||||
val okHttpClient = clientField.get(feedFetcher) as okhttp3.OkHttpClient
|
||||
|
||||
assertEquals(5000, okHttpClient.connectTimeoutMillis)
|
||||
assertEquals(5000, okHttpClient.readTimeoutMillis)
|
||||
assertEquals(5000, okHttpClient.writeTimeoutMillis)
|
||||
assertNotNull(okHttpClient.eventListenerFactory)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFetchWithHTTPAuth() {
|
||||
val auth = HTTPAuthCredentials("user", "pass")
|
||||
val credentials = auth.toCredentials()
|
||||
|
||||
assertNotNull(credentials)
|
||||
assertTrue(credentials.startsWith("Basic "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFetchWithETag() {
|
||||
val feedFetcher = FeedFetcher(timeoutMs = 15000)
|
||||
val etag = "test-etag-123"
|
||||
|
||||
val result = feedFetcher.fetch("https://example.com/feed.xml", ifNoneMatch = etag)
|
||||
assertTrue(result.isSuccess() || result.isFailure())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFetchWithLastModified() {
|
||||
val feedFetcher = FeedFetcher(timeoutMs = 15000)
|
||||
val lastModified = "Mon, 01 Jan 2024 00:00:00 GMT"
|
||||
|
||||
val result = feedFetcher.fetch("https://example.com/feed.xml", ifModifiedSince = lastModified)
|
||||
assertTrue(result.isSuccess() || result.isFailure())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFetchRetrySuccess() {
|
||||
val feedFetcher = FeedFetcher(timeoutMs = 15000, maxRetries = 3)
|
||||
|
||||
val result = feedFetcher.fetch("https://example.com/feed.xml")
|
||||
assertTrue(result.isSuccess() || result.isFailure())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.rssuper.services
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class FetchResultTest {
|
||||
|
||||
@Test
|
||||
fun testFetchResultCreation() {
|
||||
val result = FetchResult(
|
||||
feedXml = "<rss>test</rss>",
|
||||
url = "https://example.com/feed.xml",
|
||||
cacheControl = null,
|
||||
isCached = false
|
||||
)
|
||||
|
||||
assertEquals("<rss>test</rss>", result.feedXml)
|
||||
assertEquals("https://example.com/feed.xml", result.url)
|
||||
assertEquals(false, result.isCached)
|
||||
assertEquals(null, result.cacheControl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFetchResultWithETag() {
|
||||
val result = FetchResult(
|
||||
feedXml = "<rss>test</rss>",
|
||||
url = "https://example.com/feed.xml",
|
||||
cacheControl = null,
|
||||
isCached = false,
|
||||
etag = "test-etag-123"
|
||||
)
|
||||
|
||||
assertEquals("test-etag-123", result.etag)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFetchResultWithLastModified() {
|
||||
val result = FetchResult(
|
||||
feedXml = "<rss>test</rss>",
|
||||
url = "https://example.com/feed.xml",
|
||||
cacheControl = null,
|
||||
isCached = false,
|
||||
lastModified = "Mon, 01 Jan 2024 00:00:00 GMT"
|
||||
)
|
||||
|
||||
assertEquals("Mon, 01 Jan 2024 00:00:00 GMT", result.lastModified)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFetchResultIsCached() {
|
||||
val result = FetchResult(
|
||||
feedXml = "<rss>test</rss>",
|
||||
url = "https://example.com/feed.xml",
|
||||
cacheControl = null,
|
||||
isCached = true
|
||||
)
|
||||
|
||||
assertTrue(result.isCached)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFetchResultWithCacheControl() {
|
||||
val cacheControl = okhttp3.CacheControl.Builder()
|
||||
.noCache()
|
||||
.build()
|
||||
|
||||
val result = FetchResult(
|
||||
feedXml = "<rss>test</rss>",
|
||||
url = "https://example.com/feed.xml",
|
||||
cacheControl = cacheControl,
|
||||
isCached = false
|
||||
)
|
||||
|
||||
assertNotNull(result.cacheControl)
|
||||
assertTrue(result.cacheControl!!.noCache)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.rssuper.services
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class HTTPAuthCredentialsTest {
|
||||
|
||||
@Test
|
||||
fun testBasicAuthCredentials() {
|
||||
val auth = HTTPAuthCredentials("username", "password")
|
||||
val credentials = auth.toCredentials()
|
||||
|
||||
assertNotNull(credentials)
|
||||
assertTrue(credentials.startsWith("Basic "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testBasicAuthCredentialsWithSpecialChars() {
|
||||
val auth = HTTPAuthCredentials("user@domain", "pass!@#")
|
||||
val credentials = auth.toCredentials()
|
||||
|
||||
assertNotNull(credentials)
|
||||
assertTrue(credentials.startsWith("Basic "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUsernameAndPassword() {
|
||||
val auth = HTTPAuthCredentials("testuser", "testpass")
|
||||
|
||||
assertEquals("testuser", auth.username)
|
||||
assertEquals("testpass", auth.password)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEmptyUsername() {
|
||||
val auth = HTTPAuthCredentials("", "password")
|
||||
val credentials = auth.toCredentials()
|
||||
|
||||
assertNotNull(credentials)
|
||||
assertTrue(credentials.startsWith("Basic "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEmptyPassword() {
|
||||
val auth = HTTPAuthCredentials("username", "")
|
||||
val credentials = auth.toCredentials()
|
||||
|
||||
assertNotNull(credentials)
|
||||
assertTrue(credentials.startsWith("Basic "))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user