import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:tsnet_flutter/tsnet_flutter.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); test('status exposes stable node ID and chooses IPv4', () { final status = TailscaleStatus.fromJson({ 'BackendState': 'Running', 'Self': { 'ID': 'node-stable-id', 'TailscaleIPs': ['fd7a:115c:a1e0::1', '100.100.10.20'], }, }); expect(status.isRunning, isTrue); expect(status.selfNodeId, 'node-stable-id'); expect(status.selfIP, '100.100.10.20'); }); test('status does not expose IPv6 as the enrollment address', () { final status = TailscaleStatus.fromJson({ 'BackendState': 'Running', 'Self': { 'ID': 'node-stable-id', 'TailscaleIPs': ['fd7a:115c:a1e0::1'], }, }); expect(status.selfIP, isNull); }); group('proxy handles over the method channel', () { const channel = MethodChannel('tsnet_flutter'); late TsnetFlutter tsnet; late List calls; late int nextHandle; setUp(() { tsnet = TsnetFlutter(); calls = []; nextHandle = 0; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (call) async { calls.add(call); switch (call.method) { case 'startProxyHandle': nextHandle++; return {'handle': nextHandle, 'localPort': 50000 + nextHandle}; case 'stopProxyHandle': return null; case 'startProxy': return 61000; case 'stopProxy': return null; } throw MissingPluginException(call.method); }); }); tearDown(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, null); }); test( 'startProxyHandle passes the remote and returns handle plus port', () async { final proxy = await tsnet.startProxyHandle('100.64.0.5', port: 5050); expect(proxy.id, 1); expect(proxy.localPort, 50001); expect(calls, hasLength(1)); expect(calls.single.method, 'startProxyHandle'); expect(calls.single.arguments, {'ip': '100.64.0.5', 'port': 5050}); }, ); test('concurrent starts return independent handles', () async { final a = await tsnet.startProxyHandle('100.64.0.5'); final b = await tsnet.startProxyHandle('100.64.0.6'); final c = await tsnet.startProxyHandle('100.64.0.7'); expect({a.id, b.id, c.id}, hasLength(3)); expect({a.localPort, b.localPort, c.localPort}, hasLength(3)); expect(calls.map((c) => c.method), everyElement('startProxyHandle')); }); test('stopProxyHandle stops exactly the given handle', () async { final a = await tsnet.startProxyHandle('100.64.0.5'); final b = await tsnet.startProxyHandle('100.64.0.6'); await tsnet.stopProxyHandle(a); final stops = calls.where((c) => c.method == 'stopProxyHandle'); expect(stops, hasLength(1)); expect(stops.single.arguments, {'handle': a.id}); expect(b.id, isNot(a.id)); }); test('legacy startProxy and stopProxy keep their wire contract', () async { final port = await tsnet.startProxy('100.64.0.5', port: 5050); await tsnet.stopProxy(); expect(port, 61000); expect(calls, hasLength(2)); expect(calls[0].method, 'startProxy'); expect(calls[0].arguments, {'ip': '100.64.0.5', 'port': 5050}); expect(calls[1].method, 'stopProxy'); expect(calls[1].arguments, isNull); }); test('proxy handles compare by value', () { const a = TsnetProxyHandle(id: 3, localPort: 50003); const b = TsnetProxyHandle(id: 3, localPort: 50003); const c = TsnetProxyHandle(id: 4, localPort: 50004); expect(a, b); expect(a.hashCode, b.hashCode); expect(a, isNot(c)); }); }); group('pingPeer over the method channel', () { const channel = MethodChannel('tsnet_flutter'); late TsnetFlutter tsnet; late List calls; Object? pingError; setUp(() { tsnet = TsnetFlutter(); calls = []; pingError = null; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (call) async { calls.add(call); if (call.method == 'pingPeer') { if (pingError != null) throw pingError!; return null; } throw MissingPluginException(call.method); }); }); tearDown(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, null); }); test('passes the peer IP and reports success as true', () async { final ok = await tsnet.pingPeer('100.64.0.5'); expect(ok, isTrue); expect(calls, hasLength(1)); expect(calls.single.method, 'pingPeer'); expect(calls.single.arguments, {'ip': '100.64.0.5'}); }); test('a native error (node not up, timeout) becomes false', () async { pingError = PlatformException( code: 'PING_FAILED', message: 'not started, call TailscaleStart() first', ); final ok = await tsnet.pingPeer('100.64.0.5'); expect(ok, isFalse); expect(calls.single.method, 'pingPeer'); }); }); group('recoverPaths over the method channel', () { const channel = MethodChannel('tsnet_flutter'); late TsnetFlutter tsnet; late List calls; Object? recoverError; setUp(() { tsnet = TsnetFlutter(); calls = []; recoverError = null; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (call) async { calls.add(call); if (call.method == 'recoverPaths') { if (recoverError != null) throw recoverError!; return null; } throw MissingPluginException(call.method); }); }); tearDown(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, null); }); test('invokes recoverPaths with no arguments', () async { await tsnet.recoverPaths(); expect(calls, hasLength(1)); expect(calls.single.method, 'recoverPaths'); expect(calls.single.arguments, isNull); }); test('a stopped node surfaces as a PlatformException', () async { recoverError = PlatformException( code: 'RECOVER_FAILED', message: 'not started, call TailscaleStart() first', ); expect( () => tsnet.recoverPaths(), throwsA( isA().having( (e) => e.code, 'code', 'RECOVER_FAILED', ), ), ); }); }); group('start over the method channel', () { const channel = MethodChannel('tsnet_flutter'); late TsnetFlutter tsnet; late List calls; setUp(() { tsnet = TsnetFlutter(); calls = []; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (call) async { calls.add(call); if (call.method == 'start') return null; throw MissingPluginException(call.method); }); }); tearDown(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, null); }); test('debugNetLogging defaults to off', () async { await tsnet.start(hostname: 'bench'); expect(calls.single.method, 'start'); expect(calls.single.arguments, { 'authKey': '', 'hostname': 'bench', 'debugNetLogging': false, }); }); test('debugNetLogging is passed through when requested', () async { await tsnet.start(hostname: 'bench', debugNetLogging: true); expect(calls.single.arguments, { 'authKey': '', 'hostname': 'bench', 'debugNetLogging': true, }); }); }); }