[{"data":1,"prerenderedAt":87},["ShallowReactive",2],{"tutorial-grpc-e-protobuf-in-flutter-client-tipizzato-interceptor-e-streaming-bidirezionale":3,"comments-tutorial-grpc-e-protobuf-in-flutter-client-tipizzato-interceptor-e-streaming-bidirezionale":86},{"id":4,"title":5,"slug":6,"excerpt":7,"intro":8,"cover_image":9,"cover_remote_url":10,"cover_credit":11,"video_url":15,"video":16,"difficulty":20,"estimated_minutes":21,"flutter_version":22,"status":23,"published_at":24,"meta_title":25,"meta_description":26,"category":27,"author":31,"steps":33},115,"gRPC e Protobuf in Flutter: client tipizzato, interceptor e streaming bidirezionale","grpc-e-protobuf-in-flutter-client-tipizzato-interceptor-e-streaming-bidirezionale","Guida avanzata per integrare gRPC in Flutter: definizione dei contratti .proto, generazione del codice Dart, canale sicuro con TLS, interceptor di autenticazione, retry con backoff e streaming realtime.","REST non è l'unica opzione per far dialogare un'app Flutter con il backend. Quando servono **contratti tipizzati**, payload binari compatti e **streaming bidirezionale** a bassa latenza, gRPC è spesso la scelta migliore: un unico file `.proto` genera sia il client Dart sia il server, eliminando intere categorie di bug di serializzazione.\n\nIn questo tutorial avanzato costruiamo un layer gRPC *production-ready* per un'app Flutter:\n\n- generazione del codice Dart da Protocol Buffers con `protoc` e `protoc_plugin`;\n- creazione di un `ClientChannel` configurato per ambienti diversi (TLS, keepalive, deadline);\n- un `ClientInterceptor` per iniettare il token di autenticazione e tracciare le chiamate;\n- mappatura di `GrpcError` in errori di dominio, con **retry esponenziale** solo sui codici idempotenti\u002Ftransitori;\n- consumo di uno **stream server-side** con riconnessione automatica e integrazione in UI tramite `StreamBuilder`;\n- test di integrazione contro un server gRPC in-process.\n\n> Prerequisiti: Flutter 3.x, conoscenza di Dart async\u002FStream, un backend gRPC raggiungibile (o il server fake che costruiremo nei test). Su **Flutter Web** il protocollo nativo non è supportato dal browser: serve gRPC-Web dietro un proxy (Envoy) e il pacchetto `grpc\u002Fgrpc_web.dart`; lo vedremo in chiusura.","https:\u002F\u002Fflutter.it\u002Fstorage\u002Ftutorials\u002F3994ab10-833c-426e-a4e7-91a90200aab6.jpg","https:\u002F\u002Fimages.unsplash.com\u002Fphoto-1680691257251-5fead813b73e?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w5NzA2NTJ8MHwxfHJhbmRvbXx8fHx8fHx8fDE3OTExNzQ3MTh8&ixlib=rb-4.1.0&q=80&w=1080",{"name":12,"author_url":13,"photo_url":14},"Dimitri Karastelev","https:\u002F\u002Funsplash.com\u002F@dkfra19","https:\u002F\u002Funsplash.com\u002Fphotos\u002Fa-close-up-of-a-network-switch-box-pl6My-v7gnE","https:\u002F\u002Fwww.youtube.com\u002Fwatch?v=YGZ7yvozRkw",{"youtube_id":17,"duration_seconds":18,"published_at":19},"YGZ7yvozRkw",139,"2026-10-05T11:57:34+00:00","advanced",55,"3.x","published","2026-10-05T04:31:58+00:00","gRPC in Flutter: protobuf, interceptor e streaming","Tutorial avanzato su gRPC in Flutter: generazione codice da .proto, canale TLS, interceptor di auth, retry con backoff, streaming realtime e test in-process.",{"id":28,"name":29,"slug":30},1,"Guide","guide",{"id":28,"name":32},"Flutter Bot",[34,42,50,58,65,72,79],{"id":35,"position":28,"title":36,"body":37,"code_snippet":38,"code_language":39,"expected_result":40,"demo_url":41,"video_url":41},770,"Preparare toolchain e dipendenze","gRPC in Dart richiede due cose distinte: le **librerie runtime** (dichiarate nel `pubspec.yaml`) e il **compilatore** `protoc` con il plugin Dart, che gira solo in fase di sviluppo.\n\n1. Installa `protoc` (su macOS `brew install protobuf`, su Debian\u002FUbuntu `apt install -y protobuf-compiler`, su Windows scarica il binario dalle release di protocolbuffers).\n2. Installa globalmente il plugin Dart e assicurati che `~\u002F.pub-cache\u002Fbin` sia nel `PATH`.\n3. Aggiungi `grpc` e `protobuf` alle dipendenze dell'app.\n\nOrganizza il progetto separando il contratto dal codice generato: i file in `lib\u002Fsrc\u002Fgenerated\u002F` **non vanno modificati a mano** e conviene escluderli dall'analisi statica per non inquinare i report di lint.","# 1) Plugin protoc per Dart\ndart pub global activate protoc_plugin\nexport PATH=\"$PATH:$HOME\u002F.pub-cache\u002Fbin\"\n\n# 2) Dipendenze nell'app Flutter\nflutter pub add grpc protobuf\nflutter pub add --dev test\n\n# 3) Struttura consigliata\n# proto\u002Fchat.proto                 \u003C- contratto (unica fonte di verità)\n# lib\u002Fsrc\u002Fgenerated\u002F               \u003C- codice generato (non versionare le modifiche manuali)\n# lib\u002Fsrc\u002Fdata\u002Fgrpc\u002F               \u003C- channel, interceptor, repository\n\n# 4) analysis_options.yaml\n# analyzer:\n#   exclude:\n#     - lib\u002Fsrc\u002Fgenerated\u002F**","bash","`protoc --version` e `which protoc-gen-dart` rispondono correttamente; il progetto compila con le dipendenze `grpc` e `protobuf` risolte.",null,{"id":43,"position":44,"title":45,"body":46,"code_snippet":47,"code_language":48,"expected_result":49,"demo_url":41,"video_url":41},771,2,"Definire il contratto .proto e generare il client Dart","Il file `.proto` è il **confine del modulo**: descrive messaggi e servizi senza alcun riferimento a Flutter. Usiamo `proto3`, includiamo una chiamata unaria, una server-streaming e una bidirezionale.\n\nAlcune regole pratiche per evitare breaking change:\n\n- i **numeri di campo** sono il vero contratto: non riusarli mai dopo una rimozione (usa `reserved`);\n- preferisci `google.protobuf.Timestamp` ai timestamp in stringa;\n- per i campi opzionali in proto3 usa `optional` (genera `hasX()`), altrimenti non distingui \"assente\" da \"valore di default\".\n\nLa generazione va automatizzata in uno script, così è riproducibile anche in CI: ogni modifica al `.proto` deve passare da `tool\u002Fprotoc.sh`, mai da editing manuale dei file generati.","\u002F\u002F proto\u002Fchat.proto\nsyntax = \"proto3\";\npackage chat.v1;\n\nimport \"google\u002Fprotobuf\u002Ftimestamp.proto\";\n\nmessage User {\n  string id = 1;\n  string display_name = 2;\n  optional string avatar_url = 3;\n}\n\nmessage GetUserRequest { string id = 1; }\n\nmessage Message {\n  string id = 1;\n  string room_id = 2;\n  User author = 3;\n  string text = 4;\n  google.protobuf.Timestamp sent_at = 5;\n}\n\nmessage WatchRequest { string room_id = 1; }\nmessage SendRequest { string room_id = 1; string text = 2; }\nmessage SendAck { string message_id = 1; }\n\nservice ChatService {\n  rpc GetUser(GetUserRequest) returns (User);\n  rpc WatchMessages(WatchRequest) returns (stream Message);\n  rpc Chat(stream SendRequest) returns (stream Message);\n}\n\n\u002F\u002F --- tool\u002Fprotoc.sh ---\n\u002F\u002F #!\u002Fusr\u002Fbin\u002Fenv bash\n\u002F\u002F set -euo pipefail\n\u002F\u002F OUT=lib\u002Fsrc\u002Fgenerated\n\u002F\u002F rm -rf \"$OUT\" && mkdir -p \"$OUT\"\n\u002F\u002F protoc --dart_out=grpc:$OUT -Iproto proto\u002F*.proto\n\u002F\u002F dart format \"$OUT\"","protobuf","Dopo `bash tool\u002Fprotoc.sh` trovi in `lib\u002Fsrc\u002Fgenerated\u002F` i file `chat.pb.dart`, `chat.pbgrpc.dart`, `chat.pbjson.dart` con la classe `ChatServiceClient` pronta all'uso.",{"id":51,"position":52,"title":53,"body":54,"code_snippet":55,"code_language":56,"expected_result":57,"demo_url":41,"video_url":41},772,3,"Costruire il canale: TLS, keepalive e deadline per ambiente","Il `ClientChannel` è una risorsa **costosa e condivisa**: va creato una sola volta per host e chiuso allo shutdown dell'app. Evita di istanziarlo dentro un widget o a ogni chiamata.\n\nPunti chiave della configurazione:\n\n- **Credenziali**: `ChannelCredentials.secure()` in produzione, `insecure()` solo verso `localhost` in sviluppo (ricorda che su Android l'emulatore vede l'host come `10.0.2.2`).\n- **Keepalive**: indispensabile su mobile, dove NAT e radio chiudono connessioni idle; invia PING periodici per tenere viva la connessione HTTP\u002F2.\n- **Deadline**: gRPC non ha un \"timeout globale\", si usa `CallOptions(timeout: ...)` per chiamata. Le unarie devono *sempre* avere una deadline; gli stream long-lived no, altrimenti vengono chiusi.\n- **Idle timeout**: lascia che il canale si spenga quando inattivo per risparmiare batteria.","import 'package:grpc\u002Fgrpc.dart';\nimport '..\u002Fgenerated\u002Fchat.pbgrpc.dart';\n\nclass GrpcEnvironment {\n  const GrpcEnvironment({\n    required this.host,\n    required this.port,\n    required this.useTls,\n  });\n\n  final String host;\n  final int port;\n  final bool useTls;\n}\n\nclass GrpcClientFactory {\n  GrpcClientFactory(this._env, this._interceptors);\n\n  final GrpcEnvironment _env;\n  final List\u003CClientInterceptor> _interceptors;\n  ClientChannel? _channel;\n\n  ClientChannel get channel => _channel ??= ClientChannel(\n        _env.host,\n        port: _env.port,\n        options: ChannelOptions(\n          credentials: _env.useTls\n              ? const ChannelCredentials.secure()\n              : const ChannelCredentials.insecure(),\n          idleTimeout: const Duration(minutes: 5),\n          connectionTimeout: const Duration(seconds: 10),\n          keepAlive: const ClientKeepAliveOptions(\n            pingInterval: Duration(seconds: 30),\n            timeout: Duration(seconds: 10),\n            permitWithoutCalls: false,\n          ),\n          userAgent: 'flutter-chat\u002F1.0',\n        ),\n      );\n\n  ChatServiceClient get chat => ChatServiceClient(\n        channel,\n        interceptors: _interceptors,\n        options: CallOptions(timeout: const Duration(seconds: 15)),\n      );\n\n  Future\u003Cvoid> dispose() async {\n    await _channel?.shutdown();\n    _channel = null;\n  }\n}","dart","Una singola istanza di `GrpcClientFactory` (registrata nel tuo DI) espone `chat` come client tipizzato; `dispose()` chiude il canale senza lasciare socket aperti.",{"id":59,"position":60,"title":61,"body":62,"code_snippet":63,"code_language":56,"expected_result":64,"demo_url":41,"video_url":41},773,4,"Interceptor di autenticazione, tracing e refresh del token","Un `ClientInterceptor` intercetta **tutte** le chiamate (unarie e streaming) prima che partano: è il posto giusto per header di autenticazione, correlation id e logging dei tempi.\n\nDue strategie per i metadata:\n\n1. `CallOptions(providers: [...])`: callback asincrona eseguita **a ogni chiamata**, ideale perché legge il token aggiornato dal secure storage.\n2. Merge di `CallOptions` dentro l'interceptor, utile per header statici (versione app, locale).\n\nPer il **refresh del token** non fare retry dentro l'interceptor della stessa chiamata: intercetta `StatusCode.unauthenticated` nel layer repository, rinnova il token e ripeti l'invocazione una sola volta, proteggendo il refresh con un `Completer` condiviso per evitare chiamate concorrenti.","import 'dart:developer' as dev;\nimport 'package:grpc\u002Fgrpc.dart';\n\ntypedef TokenProvider = Future\u003CString?> Function();\n\nclass AuthInterceptor extends ClientInterceptor {\n  AuthInterceptor(this._tokenProvider, {this.appVersion = '1.0.0'});\n\n  final TokenProvider _tokenProvider;\n  final String appVersion;\n\n  CallOptions _withAuth(CallOptions options) => options.mergedWith(\n        CallOptions(\n          metadata: {'x-app-version': appVersion},\n          providers: [\n            (Map\u003CString, String> metadata, String uri) async {\n              final token = await _tokenProvider();\n              if (token != null && token.isNotEmpty) {\n                metadata['authorization'] = 'Bearer $token';\n              }\n            },\n          ],\n        ),\n      );\n\n  @override\n  ResponseFuture\u003CR> interceptUnary\u003CQ, R>(\n    ClientMethod\u003CQ, R> method,\n    Q request,\n    CallOptions options,\n    ClientUnaryInvoker\u003CQ, R> invoker,\n  ) =>\n      invoker(method, request, _withAuth(options));\n\n  @override\n  ResponseStream\u003CR> interceptStreaming\u003CQ, R>(\n    ClientMethod\u003CQ, R> method,\n    Stream\u003CQ> requests,\n    CallOptions options,\n    ClientStreamingInvoker\u003CQ, R> invoker,\n  ) =>\n      invoker(method, requests, _withAuth(options));\n}\n\nclass LoggingInterceptor extends ClientInterceptor {\n  @override\n  ResponseFuture\u003CR> interceptUnary\u003CQ, R>(\n    ClientMethod\u003CQ, R> method,\n    Q request,\n    CallOptions options,\n    ClientUnaryInvoker\u003CQ, R> invoker,\n  ) {\n    final sw = Stopwatch()..start();\n    final call = invoker(method, request, options);\n    call.then(\n      (_) => dev.log('${method.path} ok in ${sw.elapsedMilliseconds}ms', name: 'grpc'),\n      onError: (Object e) =>\n          dev.log('${method.path} KO in ${sw.elapsedMilliseconds}ms: $e', name: 'grpc'),\n    );\n    return call;\n  }\n}","Ogni RPC parte con gli header `authorization` e `x-app-version`; nei log di DevTools vedi path del metodo, esito e durata di ogni chiamata.",{"id":66,"position":67,"title":68,"body":69,"code_snippet":70,"code_language":56,"expected_result":71,"demo_url":41,"video_url":41},774,5,"Mappare GrpcError in errori di dominio e implementare il retry","`GrpcError` espone un `code` numerico standard: trasformarlo in un errore di dominio evita che `package:grpc` si propaghi fino alla UI.\n\nRegola d'oro del retry: **ritenta solo ciò che è sicuro ritentare**. `unavailable` e `deadlineExceeded` sono tipicamente transitori; `invalidArgument`, `permissionDenied`, `notFound` no. Per le chiamate non idempotenti (es. invio messaggio) usa una **idempotency key** nei metadata, così il server può deduplicare i tentativi.\n\nIl backoff deve essere **esponenziale con jitter**: senza jitter, tutti i client riconnettono nello stesso istante dopo un disservizio, generando un thundering herd.","import 'dart:async';\nimport 'dart:math';\nimport 'package:grpc\u002Fgrpc.dart';\n\nsealed class AppFailure implements Exception {\n  const AppFailure(this.message);\n  final String message;\n}\n\nclass NetworkFailure extends AppFailure { const NetworkFailure(super.m); }\nclass AuthFailure extends AppFailure { const AuthFailure(super.m); }\nclass NotFoundFailure extends AppFailure { const NotFoundFailure(super.m); }\nclass ServerFailure extends AppFailure { const ServerFailure(super.m); }\n\nAppFailure mapGrpcError(GrpcError e) => switch (e.code) {\n      StatusCode.unavailable || StatusCode.deadlineExceeded =>\n        const NetworkFailure('Server non raggiungibile, riprova.'),\n      StatusCode.unauthenticated || StatusCode.permissionDenied =>\n        const AuthFailure('Sessione non valida.'),\n      StatusCode.notFound => const NotFoundFailure('Risorsa inesistente.'),\n      _ => ServerFailure(e.message ?? 'Errore imprevisto (${e.code}).'),\n    };\n\nconst _retryable = {StatusCode.unavailable, StatusCode.deadlineExceeded, StatusCode.resourceExhausted};\nfinal _rnd = Random();\n\nFuture\u003CT> retryUnary\u003CT>(\n  Future\u003CT> Function() action, {\n  int maxAttempts = 3,\n  Duration base = const Duration(milliseconds: 300),\n}) async {\n  var attempt = 0;\n  while (true) {\n    attempt++;\n    try {\n      return await action();\n    } on GrpcError catch (e) {\n      final canRetry = _retryable.contains(e.code) && attempt \u003C maxAttempts;\n      if (!canRetry) throw mapGrpcError(e);\n      final expo = base * pow(2, attempt - 1).toInt();\n      final jitter = Duration(milliseconds: _rnd.nextInt(250));\n      await Future\u003Cvoid>.delayed(expo + jitter);\n    }\n  }\n}\n\nclass ChatRepository {\n  ChatRepository(this._factory);\n  final GrpcClientFactory _factory;\n\n  Future\u003CUser> getUser(String id) => retryUnary(\n        () => _factory.chat\n            .getUser(GetUserRequest(id: id),\n                options: CallOptions(timeout: const Duration(seconds: 8)))\n            .then((u) => u),\n      );\n}","La UI riceve solo `AppFailure` tipizzate; staccando la rete, una chiamata unaria viene ritentata fino a 3 volte con attese crescenti prima di fallire con `NetworkFailure`.",{"id":73,"position":74,"title":75,"body":76,"code_snippet":77,"code_language":56,"expected_result":78,"demo_url":41,"video_url":41},775,6,"Streaming server-side con riconnessione automatica","Le `rpc ... returns (stream ...)` producono una `ResponseStream`: un `Stream` Dart arricchito con `headers`, `trailers` e `cancel()`.\n\nPer un flusso realtime affidabile su mobile servono tre accorgimenti:\n\n1. **Nessuna deadline** sullo stream long-lived (`timeout: null`), altrimenti verrà chiuso dal client.\n2. **Riconnessione** con backoff quando il server chiude con `unavailable`: incapsula lo stream in uno `StreamController` che ricrea la sottoscrizione, passando l'ultimo cursore ricevuto (`lastMessageId`) per non perdere eventi.\n3. **Cancellazione deterministica**: quando l'ascoltatore si disiscrive (`onCancel`) devi chiamare `cancel()` sulla `ResponseStream`, altrimenti la chiamata resta aperta lato server.\n\nUsa `broadcast: false` e lascia che sia la UI a sottoscriversi tramite `StreamBuilder`: il ciclo di vita del widget guida quello della RPC.","import 'dart:async';\nimport 'package:grpc\u002Fgrpc.dart';\n\nextension ChatStreams on ChatRepository {\n  Stream\u003CMessage> watchMessages(String roomId) {\n    late StreamController\u003CMessage> controller;\n    StreamSubscription\u003CMessage>? sub;\n    ResponseStream\u003CMessage>? call;\n    var attempt = 0;\n    var closed = false;\n\n    Future\u003Cvoid> connect() async {\n      if (closed) return;\n      call = _factory.chat.watchMessages(\n        WatchRequest(roomId: roomId),\n        options: CallOptions(timeout: null), \u002F\u002F stream long-lived\n      );\n      sub = call!.listen(\n        (msg) {\n          attempt = 0; \u002F\u002F connessione sana: azzera il backoff\n          controller.add(msg);\n        },\n        onError: (Object e, StackTrace st) async {\n          if (e is GrpcError && e.code == StatusCode.unavailable) {\n            attempt++;\n            if (attempt > 5) {\n              controller.addError(mapGrpcError(e), st);\n              await controller.close();\n              return;\n            }\n            await Future\u003Cvoid>.delayed(Duration(seconds: 1 \u003C\u003C (attempt - 1)));\n            await connect();\n          } else {\n            controller.addError(\n              e is GrpcError ? mapGrpcError(e) : e,\n              st,\n            );\n          }\n        },\n        onDone: () async {\n          if (!closed) await connect();\n        },\n        cancelOnError: false,\n      );\n    }\n\n    controller = StreamController\u003CMessage>(\n      onListen: connect,\n      onCancel: () async {\n        closed = true;\n        await sub?.cancel();\n        await call?.cancel();\n      },\n    );\n    return controller.stream;\n  }\n}\n\n\u002F\u002F Uso nella UI\n\u002F\u002F StreamBuilder\u003CMessage>(\n\u002F\u002F   stream: repository.watchMessages(roomId),\n\u002F\u002F   builder: (context, snapshot) {\n\u002F\u002F     if (snapshot.hasError) return ErrorView(snapshot.error! as AppFailure);\n\u002F\u002F     ...\n\u002F\u002F   },\n\u002F\u002F )","Attivando la modalità aereo e riattivandola, lo stream si riconnette da solo entro pochi secondi senza che il widget venga ricostruito manualmente; uscendo dalla schermata la RPC viene chiusa lato server.",{"id":80,"position":81,"title":82,"body":83,"code_snippet":84,"code_language":56,"expected_result":85,"demo_url":41,"video_url":41},776,7,"Testare il client contro un server gRPC in-process (e il caso Web)","Il vantaggio di gRPC in test è che puoi avviare un **vero server** in-process: niente mock fragili, verifichi anche serializzazione, metadata e codici di stato.\n\nNel test:\n\n- implementa `ChatServiceBase` con comportamenti controllati (errori, stream finiti, ritardi);\n- avvia `Server.create(...)` su `port: 0` e leggi `server.port`;\n- punta il `ClientChannel` a `127.0.0.1` con credenziali insecure.\n\nPer la CI ricorda di eseguire `tool\u002Fprotoc.sh` e fallire la build se `git diff --exit-code lib\u002Fsrc\u002Fgenerated` rileva codice generato non allineato al `.proto`.\n\n**Flutter Web**: il browser non espone i frame HTTP\u002F2 necessari a gRPC. Serve un proxy che parli gRPC-Web (Envoy o `grpcwebproxy`) e, lato Dart, `GrpcWebClientChannel.xhr(Uri.parse('https:\u002F\u002Fapi.example.com'))` al posto di `ClientChannel`. Astrai la creazione del canale dietro un'interfaccia con implementazioni condizionali (`io.dart` \u002F `web.dart`) così il repository resta identico su tutte le piattaforme. Nota che lo **streaming bidirezionale non è supportato** da gRPC-Web: su web ripiega su server-streaming o WebSocket.","import 'package:grpc\u002Fgrpc.dart';\nimport 'package:test\u002Ftest.dart';\n\nclass FakeChatService extends ChatServiceBase {\n  int getUserCalls = 0;\n\n  @override\n  Future\u003CUser> getUser(ServiceCall call, GetUserRequest request) async {\n    getUserCalls++;\n    final auth = call.clientMetadata?['authorization'];\n    if (auth != 'Bearer test-token') {\n      throw GrpcError.unauthenticated('token mancante');\n    }\n    if (getUserCalls \u003C 2) {\n      throw GrpcError.unavailable('server in riavvio'); \u002F\u002F forza un retry\n    }\n    return User(id: request.id, displayName: 'Ada');\n  }\n\n  @override\n  Stream\u003CMessage> watchMessages(ServiceCall call, WatchRequest request) async* {\n    yield Message(id: '1', roomId: request.roomId, text: 'ciao');\n    yield Message(id: '2', roomId: request.roomId, text: 'mondo');\n  }\n\n  @override\n  Stream\u003CMessage> chat(ServiceCall call, Stream\u003CSendRequest> requests) async* {\n    await for (final r in requests) {\n      yield Message(id: 'echo', roomId: r.roomId, text: r.text);\n    }\n  }\n}\n\nvoid main() {\n  late Server server;\n  late GrpcClientFactory factory;\n\n  setUp(() async {\n    server = Server.create(services: [FakeChatService()]);\n    await server.serve(address: '127.0.0.1', port: 0);\n    factory = GrpcClientFactory(\n      GrpcEnvironment(host: '127.0.0.1', port: server.port!, useTls: false),\n      [AuthInterceptor(() async => 'test-token')],\n    );\n  });\n\n  tearDown(() async {\n    await factory.dispose();\n    await server.shutdown();\n  });\n\n  test('retry su unavailable e mapping del risultato', () async {\n    final user = await ChatRepository(factory).getUser('u1');\n    expect(user.displayName, 'Ada');\n  });\n\n  test('server streaming emette tutti i messaggi', () async {\n    final messages = await ChatRepository(factory).watchMessages('room-1').take(2).toList();\n    expect(messages.map((m) => m.text), ['ciao', 'mondo']);\n  });\n}","`dart test` esegue i due test in pochi millisecondi su un server gRPC reale in-process, verificando retry, metadata di autenticazione e streaming senza dipendere dalla rete.",[],1792316927614]