partially work.
issues:
1. no frontend logout when not authorized
2026-01-12T22:21:03.531+01:00 DEBUG 76514 --- [mosenioring-backend] [nio-8080-exec-1] [b137cd03c5fed357d857aa5201957552-608b35eaef78b8f9] o.s.security.web.FilterChainProxy : Securing POST /api/telemetry/test
2026-01-12T22:21:03.546+01:00 DEBUG 76514 --- [mosenioring-backend] [nio-8080-exec-1] [b137cd03c5fed357d857aa5201957552-f9d4701c12880487] o.s.s.oauth2.jwt.JwtTimestampValidator : Jwt expired at 2026-01-12T21:18:04Z
2026-01-12T22:21:03.546+01:00 DEBUG 76514 --- [mosenioring-backend] [nio-8080-exec-1] [b137cd03c5fed357d857aa5201957552-f9d4701c12880487] o.s.s.o.s.r.a.JwtAuthenticationProvider : Failed to authenticate since the JWT was invalid
2. 500
2026-01-12T22:21:59.224+01:00 DEBUG 76514 --- [mosenioring-backend] [nio-8080-exec-3] [94043d787087b50f996c7e87b550ff71-a32a7387b3d26ba2] o.s.security.web.FilterChainProxy : Secured POST /api/telemetry/test
2026-01-12T22:21:59.225+01:00 DEBUG 76514 --- [mosenioring-backend] [nio-8080-exec-3] [94043d787087b50f996c7e87b550ff71-a32a7387b3d26ba2] o.s.web.servlet.DispatcherServlet : POST "/api/telemetry/test", parameters={}
2026-01-12T22:21:59.227+01:00 DEBUG 76514 --- [mosenioring-backend] [nio-8080-exec-3] [94043d787087b50f996c7e87b550ff71-a32a7387b3d26ba2] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to com.mosenioring.app.api.TelemetryController#recordTestTelemetry(TelemetryRequest, Authentication)
2026-01-12T22:21:59.308+01:00 DEBUG 76514 --- [mosenioring-backend] [nio-8080-exec-3] [94043d787087b50f996c7e87b550ff71-a32a7387b3d26ba2] o.s.web.method.HandlerMethod : Could not resolve parameter [0] in public org.springframework.http.ResponseEntity<com.mosenioring.app.telemetry.TelemetryResponse> com.mosenioring.app.api.TelemetryController.recordTestTelemetry(com.mosenioring.app.telemetry.TelemetryRequest,org.springframework.security.core.Authentication): JSON parse error: Cannot deserialize value of type `java.time.Instant` from String "2026-01-12T22:21:58.583050": Failed to deserialize java.time.Instant: (java.time.format.DateTimeParseException) Text '2026-01-12T22:21:58.583050' could not be parsed at index 26
2026-01-12T22:21:59.308+01:00 DEBUG 76514 --- [mosenioring-backend] [nio-8080-exec-3] [94043d787087b50f996c7e87b550ff71-a32a7387b3d26ba2] .m.m.a.ExceptionHandlerExceptionResolver : Using @ExceptionHandler com.mosenioring.common.web.ProblemDetailsAdvice#handleUnexpected(Exception, HttpServletRequest)
2026-01-12T22:21:59.337+01:00 DEBUG 76514 --- [mosenioring-backend] [nio-8080-exec-3] [94043d787087b50f996c7e87b550ff71-a32a7387b3d26ba2] o.s.w.s.m.m.a.HttpEntityMethodProcessor : Using 'application/problem+json', given [*/*] and supported [application/problem+json]
2026-01-12T22:21:59.339+01:00 DEBUG 76514 --- [mosenioring-backend] [nio-8080-exec-3] [94043d787087b50f996c7e87b550ff71-a32a7387b3d26ba2] o.s.w.s.m.m.a.HttpEntityMethodProcessor : Writing [ProblemDetail[type='https://httpstatuses.io/500', title='Unexpected error', status=500, detail='JSON (truncated)...]
2026-01-12T22:21:59.344+01:00 DEBUG 76514 --- [mosenioring-backend] [nio-8080-exec-3] [94043d787087b50f996c7e87b550ff71-a32a7387b3d26ba2] .m.m.a.ExceptionHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot deserialize value of type `java.time.Instant` from String "2026-01-12T22:21:58.583050": Failed to deserialize java.time.Instant: (java.time.format.DateTimeParseException) Text '2026-01-12T22:21:58.583050' could not be parsed at index 26]
2026-01-12T22:21:59.344+01:00 DEBUG 76514 --- [mosenioring-backend] [nio-8080-exec-3] [94043d787087b50f996c7e87b550ff71-a32a7387b3d26ba2] o.s.web.servlet.DispatcherServlet : Completed 500 INTERNAL_SERVER_ERROR
This commit is contained in:
parent
711201dd9c
commit
000a984d04
|
|
@ -33,4 +33,5 @@ dependencies {
|
|||
runtimeOnly("org.postgresql:postgresql")
|
||||
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||
testImplementation("org.springframework.security:spring-security-test")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
package com.mosenioring.app.api
|
||||
|
||||
import com.mosenioring.app.telemetry.AuthenticatedUser
|
||||
import com.mosenioring.app.telemetry.TelemetryRequest
|
||||
import com.mosenioring.app.telemetry.TelemetryResponse
|
||||
import com.mosenioring.app.telemetry.TelemetryService
|
||||
import com.mosenioring.common.security.LocalUserPrincipal
|
||||
import com.mosenioring.common.security.SecurityUtils
|
||||
import jakarta.validation.Valid
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.security.core.Authentication
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/telemetry")
|
||||
class TelemetryController(
|
||||
private val telemetryService: TelemetryService
|
||||
) {
|
||||
@PostMapping("/test")
|
||||
fun recordTestTelemetry(
|
||||
@Valid @RequestBody request: TelemetryRequest,
|
||||
authentication: Authentication
|
||||
): ResponseEntity<TelemetryResponse> {
|
||||
val user = resolveUser(authentication)
|
||||
val response = telemetryService.recordTestTelemetry(request, user)
|
||||
return ResponseEntity.ok(response)
|
||||
}
|
||||
|
||||
private fun resolveUser(authentication: Authentication): AuthenticatedUser {
|
||||
return when (authentication) {
|
||||
is JwtAuthenticationToken -> {
|
||||
val token = authentication.token
|
||||
AuthenticatedUser(
|
||||
userId = token.subject ?: "unknown",
|
||||
username = token.getClaimAsString("preferred_username"),
|
||||
email = token.getClaimAsString("email")
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
val principal = authentication.principal
|
||||
val userId = (principal as? LocalUserPrincipal)?.userId
|
||||
?: SecurityUtils.currentUserId()
|
||||
?: "unknown"
|
||||
AuthenticatedUser(
|
||||
userId = userId,
|
||||
username = null,
|
||||
email = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
package com.mosenioring.app.config
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator
|
||||
import org.springframework.security.oauth2.jwt.Jwt
|
||||
import org.springframework.security.oauth2.jwt.JwtClaimValidator
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder
|
||||
import org.springframework.security.oauth2.jwt.JwtValidators
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(SecurityProperties::class)
|
||||
class JwtDecoderConfig(
|
||||
private val securityProperties: SecurityProperties,
|
||||
@Value("\${spring.security.oauth2.resourceserver.jwt.issuer-uri}") private val issuerUri: String
|
||||
) {
|
||||
@Bean
|
||||
fun jwtDecoder(): JwtDecoder {
|
||||
val decoder = NimbusJwtDecoder.withIssuerLocation(issuerUri).build()
|
||||
val allowedIssuers = if (securityProperties.acceptedIssuers.isNotEmpty()) {
|
||||
securityProperties.acceptedIssuers
|
||||
} else {
|
||||
listOf(issuerUri)
|
||||
}
|
||||
val issuerValidator = JwtClaimValidator<String>("iss") { issuer ->
|
||||
issuer != null && allowedIssuers.contains(issuer)
|
||||
}
|
||||
val validator = DelegatingOAuth2TokenValidator(
|
||||
JwtValidators.createDefault(),
|
||||
issuerValidator
|
||||
)
|
||||
decoder.setJwtValidator(validator)
|
||||
return decoder
|
||||
}
|
||||
}
|
||||
|
||||
@ConfigurationProperties(prefix = "app.security")
|
||||
data class SecurityProperties(
|
||||
val acceptedIssuers: List<String> = emptyList()
|
||||
)
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package com.mosenioring.app.telemetry
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator
|
||||
import com.fasterxml.jackson.annotation.JsonValue
|
||||
import jakarta.validation.constraints.NotBlank
|
||||
import jakarta.validation.constraints.NotNull
|
||||
import jakarta.validation.constraints.Size
|
||||
import java.time.Instant
|
||||
|
||||
data class TelemetryRequest(
|
||||
@field:NotNull val timestamp: Instant?,
|
||||
@field:NotNull val platform: TelemetryPlatform?,
|
||||
@field:NotBlank val appVersion: String,
|
||||
@field:Size(max = 200) val note: String? = null
|
||||
)
|
||||
|
||||
data class TelemetryResponse(
|
||||
val status: String,
|
||||
val receivedAt: Instant,
|
||||
val userId: String,
|
||||
val echo: TelemetryEcho
|
||||
)
|
||||
|
||||
data class TelemetryEcho(
|
||||
val timestamp: Instant,
|
||||
val platform: TelemetryPlatform,
|
||||
val appVersion: String,
|
||||
val note: String?
|
||||
)
|
||||
|
||||
data class AuthenticatedUser(
|
||||
val userId: String,
|
||||
val username: String?,
|
||||
val email: String?
|
||||
)
|
||||
|
||||
enum class TelemetryPlatform {
|
||||
ANDROID,
|
||||
IOS;
|
||||
|
||||
@JsonValue
|
||||
fun toJson(): String = name.lowercase()
|
||||
|
||||
companion object {
|
||||
@JsonCreator
|
||||
@JvmStatic
|
||||
fun fromJson(value: String): TelemetryPlatform {
|
||||
return when (value.trim().lowercase()) {
|
||||
"android" -> ANDROID
|
||||
"ios" -> IOS
|
||||
else -> throw IllegalArgumentException("Unsupported platform: $value")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package com.mosenioring.app.telemetry
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import java.time.Instant
|
||||
|
||||
@Service
|
||||
class TelemetryService {
|
||||
private val logger = LoggerFactory.getLogger(TelemetryService::class.java)
|
||||
|
||||
fun recordTestTelemetry(request: TelemetryRequest, user: AuthenticatedUser): TelemetryResponse {
|
||||
val timestamp = requireNotNull(request.timestamp) { "Missing timestamp" }
|
||||
val platform = requireNotNull(request.platform) { "Missing platform" }
|
||||
val echo = TelemetryEcho(
|
||||
timestamp = timestamp,
|
||||
platform = platform,
|
||||
appVersion = request.appVersion,
|
||||
note = request.note
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Test telemetry received userId={} platform={} timestamp={}",
|
||||
user.userId,
|
||||
platform,
|
||||
timestamp
|
||||
)
|
||||
|
||||
return TelemetryResponse(
|
||||
status = "ok",
|
||||
receivedAt = Instant.now(),
|
||||
userId = user.userId,
|
||||
echo = echo
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -39,6 +39,10 @@ storage:
|
|||
bucket: mosenioring
|
||||
|
||||
app:
|
||||
security:
|
||||
accepted-issuers:
|
||||
- http://localhost:8081/realms/mosenioring
|
||||
- http://10.0.2.2:8081/realms/mosenioring
|
||||
outbox:
|
||||
publish-delay-ms: 2000
|
||||
batch-size: 50
|
||||
|
|
|
|||
|
|
@ -0,0 +1,116 @@
|
|||
package com.mosenioring.app.api
|
||||
|
||||
import com.mosenioring.app.config.SecurityConfig
|
||||
import com.mosenioring.app.telemetry.AuthenticatedUser
|
||||
import com.mosenioring.app.telemetry.TelemetryEcho
|
||||
import com.mosenioring.app.telemetry.TelemetryPlatform
|
||||
import com.mosenioring.app.telemetry.TelemetryRequest
|
||||
import com.mosenioring.app.telemetry.TelemetryResponse
|
||||
import com.mosenioring.app.telemetry.TelemetryService
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.ArgumentMatchers
|
||||
import org.mockito.Mockito.`when`
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.autoconfigure.ImportAutoConfiguration
|
||||
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
|
||||
import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration
|
||||
import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration
|
||||
import org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
|
||||
import org.springframework.boot.test.mock.mockito.MockBean
|
||||
import org.springframework.context.annotation.Import
|
||||
import org.springframework.test.context.ContextConfiguration
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import java.time.Instant
|
||||
|
||||
@WebMvcTest(controllers = [TelemetryController::class])
|
||||
@ContextConfiguration(classes = [TelemetryController::class, SecurityConfig::class])
|
||||
@ImportAutoConfiguration(
|
||||
exclude = [
|
||||
DataSourceAutoConfiguration::class,
|
||||
DataSourceTransactionManagerAutoConfiguration::class,
|
||||
HibernateJpaAutoConfiguration::class,
|
||||
JpaRepositoriesAutoConfiguration::class
|
||||
]
|
||||
)
|
||||
@Import(SecurityConfig::class)
|
||||
@AutoConfigureMockMvc
|
||||
class TelemetryControllerTest {
|
||||
|
||||
@Autowired
|
||||
private lateinit var mockMvc: MockMvc
|
||||
|
||||
@MockBean
|
||||
private lateinit var telemetryService: TelemetryService
|
||||
|
||||
@MockBean
|
||||
private lateinit var jwtDecoder: JwtDecoder
|
||||
|
||||
@Test
|
||||
fun `returns ok when authenticated with valid payload`() {
|
||||
val response = TelemetryResponse(
|
||||
status = "ok",
|
||||
receivedAt = Instant.parse("2024-01-01T00:00:00Z"),
|
||||
userId = "user-1",
|
||||
echo = TelemetryEcho(
|
||||
timestamp = Instant.parse("2024-01-01T00:00:00Z"),
|
||||
platform = TelemetryPlatform.ANDROID,
|
||||
appVersion = "dev",
|
||||
note = "test call from mobile"
|
||||
)
|
||||
)
|
||||
`when`(
|
||||
telemetryService.recordTestTelemetry(
|
||||
anyNonNull(),
|
||||
anyNonNull()
|
||||
)
|
||||
).thenReturn(response)
|
||||
|
||||
val requestBody = """
|
||||
{
|
||||
"timestamp": "2024-01-01T00:00:00Z",
|
||||
"platform": "android",
|
||||
"appVersion": "dev",
|
||||
"note": "test call from mobile"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/telemetry/test")
|
||||
.with(jwt().jwt { it.subject("user-1") })
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(requestBody)
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.status").value("ok"))
|
||||
.andExpect(jsonPath("$.userId").value("user-1"))
|
||||
.andExpect(jsonPath("$.echo.platform").value("android"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns 400 when payload invalid`() {
|
||||
val requestBody = """
|
||||
{
|
||||
"platform": "android"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/telemetry/test")
|
||||
.with(jwt().jwt { it.subject("user-1") })
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(requestBody)
|
||||
)
|
||||
.andExpect(status().isBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun <T> anyNonNull(): T = ArgumentMatchers.any<T>() ?: null as T
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package com.mosenioring.app.telemetry
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Instant
|
||||
|
||||
class TelemetryServiceTest {
|
||||
|
||||
@Test
|
||||
fun `records telemetry and returns response`() {
|
||||
val service = TelemetryService()
|
||||
val request = TelemetryRequest(
|
||||
timestamp = Instant.parse("2024-01-02T12:00:00Z"),
|
||||
platform = TelemetryPlatform.IOS,
|
||||
appVersion = "dev",
|
||||
note = "test call from mobile"
|
||||
)
|
||||
val user = AuthenticatedUser(
|
||||
userId = "user-42",
|
||||
username = "tester",
|
||||
email = "tester@example.com"
|
||||
)
|
||||
|
||||
val before = Instant.now()
|
||||
val response = service.recordTestTelemetry(request, user)
|
||||
val after = Instant.now()
|
||||
|
||||
assertEquals("ok", response.status)
|
||||
assertEquals("user-42", response.userId)
|
||||
assertEquals(request.timestamp, response.echo.timestamp)
|
||||
assertEquals(request.platform, response.echo.platform)
|
||||
assertEquals(request.appVersion, response.echo.appVersion)
|
||||
assertEquals(request.note, response.echo.note)
|
||||
assertTrue(!response.receivedAt.isBefore(before) && !response.receivedAt.isAfter(after))
|
||||
}
|
||||
}
|
||||
82
front001/mosenioring/lib/src/core/network/http_client.dart
Normal file
82
front001/mosenioring/lib/src/core/network/http_client.dart
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'api_client.dart';
|
||||
|
||||
class HttpResponse<T> {
|
||||
const HttpResponse({
|
||||
required this.statusCode,
|
||||
this.data,
|
||||
});
|
||||
|
||||
final int statusCode;
|
||||
final T? data;
|
||||
}
|
||||
|
||||
class HttpClientException implements Exception {
|
||||
HttpClientException({
|
||||
required this.message,
|
||||
this.statusCode,
|
||||
this.isNetworkError = false,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
final bool isNetworkError;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'HttpClientException(message: $message, statusCode: $statusCode, isNetworkError: $isNetworkError)';
|
||||
}
|
||||
|
||||
factory HttpClientException.fromDio(DioException error) {
|
||||
final statusCode = error.response?.statusCode;
|
||||
final isNetworkError = error.type == DioExceptionType.connectionError ||
|
||||
error.type == DioExceptionType.connectionTimeout ||
|
||||
error.type == DioExceptionType.receiveTimeout ||
|
||||
error.type == DioExceptionType.sendTimeout;
|
||||
return HttpClientException(
|
||||
message: error.message ?? 'HTTP request failed',
|
||||
statusCode: statusCode,
|
||||
isNetworkError: isNetworkError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class HttpClient {
|
||||
Future<HttpResponse<T>> post<T>(
|
||||
String path, {
|
||||
Object? data,
|
||||
Map<String, String>? headers,
|
||||
});
|
||||
}
|
||||
|
||||
class ApiHttpClient implements HttpClient {
|
||||
ApiHttpClient(this._apiClient);
|
||||
|
||||
final ApiClient _apiClient;
|
||||
|
||||
@override
|
||||
Future<HttpResponse<T>> post<T>(
|
||||
String path, {
|
||||
Object? data,
|
||||
Map<String, String>? headers,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _apiClient.request<T>(
|
||||
path,
|
||||
method: 'POST',
|
||||
data: data,
|
||||
options: Options(
|
||||
headers: headers,
|
||||
validateStatus: (status) => status != null,
|
||||
),
|
||||
);
|
||||
return HttpResponse(
|
||||
statusCode: response.statusCode ?? 0,
|
||||
data: response.data,
|
||||
);
|
||||
} on DioException catch (error) {
|
||||
throw HttpClientException.fromDio(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,12 +5,22 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|||
|
||||
import '../core/config/app_config.dart';
|
||||
import '../core/network/api_client.dart';
|
||||
import '../core/network/http_client.dart';
|
||||
import '../features/auth/data/auth_local_data_source.dart';
|
||||
import '../features/auth/data/auth_remote_data_source.dart';
|
||||
import '../features/auth/data/auth_repository_impl.dart';
|
||||
import '../features/auth/domain/auth_repository.dart';
|
||||
import '../features/auth/presentation/auth_controller.dart';
|
||||
import '../features/auth/presentation/auth_state.dart';
|
||||
import '../features/telemetry/data/auth_token_provider.dart';
|
||||
import '../features/telemetry/data/telemetry_remote_data_source.dart';
|
||||
import '../features/telemetry/data/telemetry_service_impl.dart';
|
||||
import '../features/telemetry/domain/platform_info.dart';
|
||||
import '../features/telemetry/domain/telemetry_payload_builder.dart';
|
||||
import '../features/telemetry/domain/telemetry_service.dart';
|
||||
import '../features/telemetry/domain/token_provider.dart';
|
||||
import '../features/telemetry/presentation/telemetry_controller.dart';
|
||||
import '../features/telemetry/presentation/telemetry_state.dart';
|
||||
|
||||
final appConfigProvider = Provider<AppConfig>((ref) {
|
||||
const apiBaseUrl = String.fromEnvironment('API_BASE_URL', defaultValue: '');
|
||||
|
|
@ -142,3 +152,39 @@ final authRepositoryProvider = Provider<AuthRepository>((ref) {
|
|||
final authControllerProvider = NotifierProvider<AuthController, AuthState>(() {
|
||||
return AuthController();
|
||||
});
|
||||
|
||||
final httpClientProvider = Provider<HttpClient>((ref) {
|
||||
return ApiHttpClient(ref.watch(apiClientProvider));
|
||||
});
|
||||
|
||||
final platformInfoProvider = Provider<PlatformInfo>((ref) {
|
||||
return const DevicePlatformInfo();
|
||||
});
|
||||
|
||||
final tokenProvider = Provider<TokenProvider>((ref) {
|
||||
return AuthTokenProvider(ref.watch(authRepositoryProvider));
|
||||
});
|
||||
|
||||
final telemetryPayloadBuilderProvider = Provider<TelemetryPayloadBuilder>((ref) {
|
||||
return TelemetryPayloadBuilder(
|
||||
platformInfo: ref.watch(platformInfoProvider),
|
||||
now: DateTime.now,
|
||||
);
|
||||
});
|
||||
|
||||
final telemetryRemoteDataSourceProvider = Provider<TelemetryRemoteDataSource>((ref) {
|
||||
return TelemetryRemoteDataSource(ref.watch(httpClientProvider));
|
||||
});
|
||||
|
||||
final telemetryServiceProvider = Provider<TelemetryService>((ref) {
|
||||
return TelemetryServiceImpl(
|
||||
ref.watch(telemetryRemoteDataSourceProvider),
|
||||
ref.watch(tokenProvider),
|
||||
ref.watch(telemetryPayloadBuilderProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final telemetryControllerProvider =
|
||||
NotifierProvider<TelemetryController, TelemetryState>(() {
|
||||
return TelemetryController();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,13 +3,52 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||
import 'package:mosenioring/l10n/app_localizations.dart';
|
||||
|
||||
import '../../../di/providers.dart';
|
||||
import '../../telemetry/presentation/telemetry_state.dart';
|
||||
|
||||
class HomePage extends ConsumerWidget {
|
||||
class HomePage extends ConsumerStatefulWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<HomePage> createState() => _HomePageState();
|
||||
}
|
||||
|
||||
class _HomePageState extends ConsumerState<HomePage> {
|
||||
late final ProviderSubscription<TelemetryState> _telemetrySubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_telemetrySubscription =
|
||||
ref.listenManual<TelemetryState>(telemetryControllerProvider, (previous, next) {
|
||||
if (previous?.lastOutcome == next.lastOutcome ||
|
||||
next.lastOutcome == null ||
|
||||
!mounted) {
|
||||
return;
|
||||
}
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
if (next.lastOutcome == TelemetryOutcome.success) {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('Telemetry sent')),
|
||||
);
|
||||
} else if (next.lastOutcome == TelemetryOutcome.failure) {
|
||||
final message = next.errorMessage ?? 'Telemetry request failed';
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(message)),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_telemetrySubscription.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final telemetryState = ref.watch(telemetryControllerProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
|
|
@ -24,8 +63,46 @@ class HomePage extends ConsumerWidget {
|
|||
),
|
||||
],
|
||||
),
|
||||
body: Center(
|
||||
child: Text(l10n.signedInMessage),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Text(
|
||||
l10n.signedInMessage,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Developer tools',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: telemetryState.isLoading
|
||||
? null
|
||||
: () {
|
||||
ref
|
||||
.read(telemetryControllerProvider.notifier)
|
||||
.sendTestTelemetry();
|
||||
},
|
||||
child: telemetryState.isLoading
|
||||
? const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Text('Sending...'),
|
||||
],
|
||||
)
|
||||
: const Text('Send test telemetry'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
import '../../auth/domain/auth_repository.dart';
|
||||
import '../domain/token_provider.dart';
|
||||
|
||||
class AuthTokenProvider implements TokenProvider {
|
||||
AuthTokenProvider(this._authRepository);
|
||||
|
||||
final AuthRepository _authRepository;
|
||||
|
||||
@override
|
||||
Future<String?> getAccessToken() async {
|
||||
final token = await _authRepository.getSavedToken();
|
||||
return token?.accessToken;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import '../../../core/network/http_client.dart';
|
||||
import '../domain/telemetry_payload.dart';
|
||||
|
||||
class TelemetryApiResponse {
|
||||
const TelemetryApiResponse({
|
||||
required this.statusCode,
|
||||
this.data,
|
||||
});
|
||||
|
||||
final int statusCode;
|
||||
final Object? data;
|
||||
}
|
||||
|
||||
class TelemetryRemoteDataSource {
|
||||
TelemetryRemoteDataSource(this._httpClient);
|
||||
|
||||
final HttpClient _httpClient;
|
||||
|
||||
Future<TelemetryApiResponse> sendTestTelemetry({
|
||||
required String accessToken,
|
||||
required TelemetryPayload payload,
|
||||
}) async {
|
||||
final response = await _httpClient.post<Object>(
|
||||
'/api/telemetry/test',
|
||||
data: payload.toJson(),
|
||||
headers: {
|
||||
'Authorization': 'Bearer $accessToken',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
);
|
||||
return TelemetryApiResponse(
|
||||
statusCode: response.statusCode,
|
||||
data: response.data,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import '../../../core/network/http_client.dart';
|
||||
import '../domain/telemetry_failure.dart';
|
||||
import '../domain/telemetry_payload_builder.dart';
|
||||
import '../domain/telemetry_service.dart';
|
||||
import '../domain/token_provider.dart';
|
||||
import 'telemetry_remote_data_source.dart';
|
||||
|
||||
class TelemetryServiceImpl implements TelemetryService {
|
||||
TelemetryServiceImpl(
|
||||
this._remoteDataSource,
|
||||
this._tokenProvider,
|
||||
this._payloadBuilder,
|
||||
);
|
||||
|
||||
final TelemetryRemoteDataSource _remoteDataSource;
|
||||
final TokenProvider _tokenProvider;
|
||||
final TelemetryPayloadBuilder _payloadBuilder;
|
||||
|
||||
@override
|
||||
Future<void> sendTestTelemetry() async {
|
||||
final accessToken = await _tokenProvider.getAccessToken();
|
||||
if (accessToken == null || accessToken.isEmpty) {
|
||||
throw TelemetryFailure(
|
||||
'Missing access token',
|
||||
type: TelemetryFailureType.unauthorized,
|
||||
debugMessage: 'Access token missing when sending telemetry',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
final payload = _payloadBuilder.build();
|
||||
final response = await _remoteDataSource.sendTestTelemetry(
|
||||
accessToken: accessToken,
|
||||
payload: payload,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200 || response.statusCode == 201) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw _failureFromStatus(response.statusCode);
|
||||
} on HttpClientException catch (error) {
|
||||
throw _failureFromHttp(error);
|
||||
} on TelemetryFailure {
|
||||
rethrow;
|
||||
} catch (error) {
|
||||
throw TelemetryFailure(
|
||||
'Unable to send telemetry',
|
||||
type: TelemetryFailureType.unknown,
|
||||
debugMessage: 'Unexpected telemetry error: ${error.runtimeType}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TelemetryFailure _failureFromStatus(int statusCode) {
|
||||
if (statusCode == 401 || statusCode == 403) {
|
||||
return TelemetryFailure(
|
||||
'Not authorized',
|
||||
type: TelemetryFailureType.unauthorized,
|
||||
statusCode: statusCode,
|
||||
debugMessage: 'Telemetry request unauthorized ($statusCode)',
|
||||
);
|
||||
}
|
||||
return TelemetryFailure(
|
||||
'Telemetry request failed',
|
||||
type: TelemetryFailureType.server,
|
||||
statusCode: statusCode,
|
||||
debugMessage: 'Telemetry request failed with status $statusCode',
|
||||
);
|
||||
}
|
||||
|
||||
TelemetryFailure _failureFromHttp(HttpClientException error) {
|
||||
if (error.isNetworkError) {
|
||||
return TelemetryFailure(
|
||||
'Network error. Try again.',
|
||||
type: TelemetryFailureType.network,
|
||||
statusCode: error.statusCode,
|
||||
debugMessage: 'Network error: ${error.message}',
|
||||
);
|
||||
}
|
||||
if (error.statusCode != null) {
|
||||
return _failureFromStatus(error.statusCode!);
|
||||
}
|
||||
return TelemetryFailure(
|
||||
'Telemetry request failed',
|
||||
type: TelemetryFailureType.unknown,
|
||||
debugMessage: 'HTTP error: ${error.message}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
|
||||
abstract class PlatformInfo {
|
||||
String get platform;
|
||||
}
|
||||
|
||||
class DevicePlatformInfo implements PlatformInfo {
|
||||
const DevicePlatformInfo();
|
||||
|
||||
@override
|
||||
String get platform {
|
||||
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||
return 'android';
|
||||
}
|
||||
if (defaultTargetPlatform == TargetPlatform.iOS) {
|
||||
return 'ios';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
enum TelemetryFailureType {
|
||||
unauthorized,
|
||||
network,
|
||||
server,
|
||||
unknown,
|
||||
}
|
||||
|
||||
class TelemetryFailure implements Exception {
|
||||
TelemetryFailure(
|
||||
this.message, {
|
||||
this.type = TelemetryFailureType.unknown,
|
||||
this.statusCode,
|
||||
this.debugMessage,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final TelemetryFailureType type;
|
||||
final int? statusCode;
|
||||
final String? debugMessage;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'TelemetryFailure(type: $type, statusCode: $statusCode, message: $message)';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
class TelemetryPayload {
|
||||
const TelemetryPayload({
|
||||
required this.timestamp,
|
||||
required this.platform,
|
||||
required this.appVersion,
|
||||
required this.note,
|
||||
});
|
||||
|
||||
final String timestamp;
|
||||
final String platform;
|
||||
final String appVersion;
|
||||
final String note;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'timestamp': timestamp,
|
||||
'platform': platform,
|
||||
'appVersion': appVersion,
|
||||
'note': note,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import 'platform_info.dart';
|
||||
import 'telemetry_payload.dart';
|
||||
|
||||
class TelemetryPayloadBuilder {
|
||||
TelemetryPayloadBuilder({
|
||||
required PlatformInfo platformInfo,
|
||||
DateTime Function()? now,
|
||||
}) : _platformInfo = platformInfo,
|
||||
_now = now ?? DateTime.now;
|
||||
|
||||
final PlatformInfo _platformInfo;
|
||||
final DateTime Function() _now;
|
||||
|
||||
TelemetryPayload build() {
|
||||
return TelemetryPayload(
|
||||
timestamp: _now().toIso8601String(),
|
||||
platform: _platformInfo.platform,
|
||||
appVersion: 'dev',
|
||||
note: 'test call from mobile',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
abstract class TelemetryService {
|
||||
Future<void> sendTestTelemetry();
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
abstract class TokenProvider {
|
||||
Future<String?> getAccessToken();
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../di/providers.dart';
|
||||
import '../domain/telemetry_failure.dart';
|
||||
import '../domain/telemetry_service.dart';
|
||||
import 'telemetry_state.dart';
|
||||
|
||||
class TelemetryController extends Notifier<TelemetryState> {
|
||||
@override
|
||||
TelemetryState build() {
|
||||
return const TelemetryState();
|
||||
}
|
||||
|
||||
TelemetryService get _service => ref.watch(telemetryServiceProvider);
|
||||
|
||||
Future<void> sendTestTelemetry() async {
|
||||
if (state.isLoading) {
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(
|
||||
isLoading: true,
|
||||
lastOutcome: null,
|
||||
errorMessage: null,
|
||||
);
|
||||
|
||||
try {
|
||||
await _service.sendTestTelemetry();
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
lastOutcome: TelemetryOutcome.success,
|
||||
errorMessage: null,
|
||||
);
|
||||
} on TelemetryFailure catch (failure) {
|
||||
_logFailure(failure);
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
lastOutcome: TelemetryOutcome.failure,
|
||||
errorMessage: failure.message,
|
||||
);
|
||||
} catch (error) {
|
||||
_logFailure(error);
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
lastOutcome: TelemetryOutcome.failure,
|
||||
errorMessage: 'Telemetry request failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _logFailure(Object error) {
|
||||
if (error is TelemetryFailure) {
|
||||
debugPrint('Telemetry failed: ${error.debugMessage ?? error.message}');
|
||||
return;
|
||||
}
|
||||
debugPrint('Telemetry failed: ${error.runtimeType}');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
enum TelemetryOutcome {
|
||||
success,
|
||||
failure,
|
||||
}
|
||||
|
||||
class TelemetryState {
|
||||
const TelemetryState({
|
||||
this.isLoading = false,
|
||||
this.lastOutcome,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
final bool isLoading;
|
||||
final TelemetryOutcome? lastOutcome;
|
||||
final String? errorMessage;
|
||||
|
||||
TelemetryState copyWith({
|
||||
bool? isLoading,
|
||||
TelemetryOutcome? lastOutcome,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return TelemetryState(
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
lastOutcome: lastOutcome,
|
||||
errorMessage: errorMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mosenioring/src/di/providers.dart';
|
||||
import 'package:mosenioring/src/features/telemetry/domain/telemetry_failure.dart';
|
||||
import 'package:mosenioring/src/features/telemetry/domain/telemetry_service.dart';
|
||||
import 'package:mosenioring/src/features/telemetry/presentation/telemetry_state.dart';
|
||||
|
||||
class FakeTelemetryService implements TelemetryService {
|
||||
FakeTelemetryService.success()
|
||||
: _behavior = _Behavior.success,
|
||||
failure = null,
|
||||
completer = null;
|
||||
FakeTelemetryService.failure(this.failure)
|
||||
: _behavior = _Behavior.failure,
|
||||
completer = null;
|
||||
FakeTelemetryService.delayed(this.completer)
|
||||
: _behavior = _Behavior.delayed,
|
||||
failure = null;
|
||||
|
||||
final _Behavior _behavior;
|
||||
final TelemetryFailure? failure;
|
||||
final Completer<void>? completer;
|
||||
|
||||
@override
|
||||
Future<void> sendTestTelemetry() {
|
||||
switch (_behavior) {
|
||||
case _Behavior.success:
|
||||
return Future.value();
|
||||
case _Behavior.failure:
|
||||
return Future.error(failure!);
|
||||
case _Behavior.delayed:
|
||||
return completer!.future;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum _Behavior {
|
||||
success,
|
||||
failure,
|
||||
delayed,
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('TelemetryController toggles loading during call', () async {
|
||||
final completer = Completer<void>();
|
||||
final service = FakeTelemetryService.delayed(completer);
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
telemetryServiceProvider.overrideWithValue(service),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final notifier = container.read(telemetryControllerProvider.notifier);
|
||||
final future = notifier.sendTestTelemetry();
|
||||
|
||||
expect(container.read(telemetryControllerProvider).isLoading, isTrue);
|
||||
|
||||
completer.complete();
|
||||
await future;
|
||||
|
||||
expect(container.read(telemetryControllerProvider).isLoading, isFalse);
|
||||
});
|
||||
|
||||
test('TelemetryController exposes success outcome', () async {
|
||||
final service = FakeTelemetryService.success();
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
telemetryServiceProvider.overrideWithValue(service),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(telemetryControllerProvider.notifier).sendTestTelemetry();
|
||||
|
||||
final state = container.read(telemetryControllerProvider);
|
||||
expect(state.lastOutcome, TelemetryOutcome.success);
|
||||
expect(state.errorMessage, isNull);
|
||||
});
|
||||
|
||||
test('TelemetryController exposes failure outcome', () async {
|
||||
final service = FakeTelemetryService.failure(
|
||||
TelemetryFailure('Boom'),
|
||||
);
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
telemetryServiceProvider.overrideWithValue(service),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(telemetryControllerProvider.notifier).sendTestTelemetry();
|
||||
|
||||
final state = container.read(telemetryControllerProvider);
|
||||
expect(state.lastOutcome, TelemetryOutcome.failure);
|
||||
expect(state.errorMessage, 'Boom');
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mosenioring/src/core/network/http_client.dart';
|
||||
import 'package:mosenioring/src/features/telemetry/data/telemetry_remote_data_source.dart';
|
||||
import 'package:mosenioring/src/features/telemetry/data/telemetry_service_impl.dart';
|
||||
import 'package:mosenioring/src/features/telemetry/domain/platform_info.dart';
|
||||
import 'package:mosenioring/src/features/telemetry/domain/telemetry_failure.dart';
|
||||
import 'package:mosenioring/src/features/telemetry/domain/telemetry_payload_builder.dart';
|
||||
import 'package:mosenioring/src/features/telemetry/domain/token_provider.dart';
|
||||
|
||||
class FakeHttpClient implements HttpClient {
|
||||
FakeHttpClient({required this.statusCode});
|
||||
|
||||
final int statusCode;
|
||||
String? lastPath;
|
||||
Object? lastBody;
|
||||
Map<String, String>? lastHeaders;
|
||||
|
||||
@override
|
||||
Future<HttpResponse<T>> post<T>(
|
||||
String path, {
|
||||
Object? data,
|
||||
Map<String, String>? headers,
|
||||
}) async {
|
||||
lastPath = path;
|
||||
lastBody = data;
|
||||
lastHeaders = headers;
|
||||
return HttpResponse<T>(
|
||||
statusCode: statusCode,
|
||||
data: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeTokenProvider implements TokenProvider {
|
||||
FakeTokenProvider(this.token);
|
||||
|
||||
final String? token;
|
||||
|
||||
@override
|
||||
Future<String?> getAccessToken() async => token;
|
||||
}
|
||||
|
||||
class FakePlatformInfo implements PlatformInfo {
|
||||
FakePlatformInfo(this.value);
|
||||
|
||||
final String value;
|
||||
|
||||
@override
|
||||
String get platform => value;
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('TelemetryService success returns without error', () async {
|
||||
final httpClient = FakeHttpClient(statusCode: 200);
|
||||
final payloadBuilder = TelemetryPayloadBuilder(
|
||||
platformInfo: FakePlatformInfo('android'),
|
||||
now: () => DateTime(2024, 1, 1),
|
||||
);
|
||||
final service = TelemetryServiceImpl(
|
||||
TelemetryRemoteDataSource(httpClient),
|
||||
FakeTokenProvider('token'),
|
||||
payloadBuilder,
|
||||
);
|
||||
|
||||
await service.sendTestTelemetry();
|
||||
|
||||
expect(httpClient.lastPath, '/api/telemetry/test');
|
||||
});
|
||||
|
||||
test('TelemetryService failure maps non-2xx response', () async {
|
||||
final httpClient = FakeHttpClient(statusCode: 500);
|
||||
final payloadBuilder = TelemetryPayloadBuilder(
|
||||
platformInfo: FakePlatformInfo('ios'),
|
||||
now: () => DateTime(2024, 1, 1),
|
||||
);
|
||||
final service = TelemetryServiceImpl(
|
||||
TelemetryRemoteDataSource(httpClient),
|
||||
FakeTokenProvider('token'),
|
||||
payloadBuilder,
|
||||
);
|
||||
|
||||
expect(
|
||||
() async => service.sendTestTelemetry(),
|
||||
throwsA(
|
||||
isA<TelemetryFailure>().having(
|
||||
(failure) => failure.type,
|
||||
'type',
|
||||
TelemetryFailureType.server,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue