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:
oskar 2026-01-12 22:23:38 +01:00
parent 711201dd9c
commit 000a984d04
24 changed files with 1071 additions and 4 deletions

View file

@ -33,4 +33,5 @@ dependencies {
runtimeOnly("org.postgresql:postgresql")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
}

View file

@ -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
)
}
}
}
}

View file

@ -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()
)

View file

@ -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")
}
}
}
}

View file

@ -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
)
}
}

View file

@ -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

View file

@ -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

View file

@ -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))
}
}

View 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);
}
}
}

View file

@ -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();
});

View file

@ -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'),
),
),
],
),
);
}

View file

@ -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;
}
}

View file

@ -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,
);
}
}

View file

@ -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}',
);
}
}

View file

@ -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';
}
}

View file

@ -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)';
}
}

View file

@ -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,
};
}
}

View file

@ -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',
);
}
}

View file

@ -0,0 +1,3 @@
abstract class TelemetryService {
Future<void> sendTestTelemetry();
}

View file

@ -0,0 +1,3 @@
abstract class TokenProvider {
Future<String?> getAccessToken();
}

View file

@ -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}');
}
}

View file

@ -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,
);
}
}

View file

@ -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');
});
}

View file

@ -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,
),
),
);
});
}