diff --git a/CLAUDE.md b/CLAUDE.md index 1b585c8..0213898 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,7 @@ For Flutter/Dart code: - `cd backend && ./run.sh` from the `backend/` folder - Run the backend - `cd frontend && ./run.sh` from the `frontend/` folder - Run the front-end for the web - `cd backend && ./run_tests.sh` - Run the backend tests +- `cd frontend && ./run_tests.sh` - Run the front-end tests - `cd backend && ./lint.sh` - Run the clippy linter for Rust code - `dart analyze` - Run the Dart linter - `cd backend/data && ./update_mac_vendors --llm` - Update the MAC vendors list from the web and re-calculate the vedors -> device type list @@ -49,7 +50,8 @@ For Flutter/Dart code: ## Important notes - NEVER add or commit .env files or files with secrets (passwords, API keys or similar information) - Code must be as simple as possible, human readable and modularized -- ALWAYS write unit tests for new or modified backend components +- ALWAYS write and/or update unit tests for new or modified backend components +- ALWAYS write and/or update unit, API and widget tests for new or modified frontend components - ALWAYS run all tests after making a new change and do not continue until all tests pass - When adding a new API endpoint, ALWAYS wire it to the OpenAPI generation - When adding a significant chunk of new code (either Rust or Dart), run the corresponding linter diff --git a/TODO.md b/TODO.md index 710878b..84e9353 100644 --- a/TODO.md +++ b/TODO.md @@ -17,7 +17,7 @@ ## Frontend -- [ ] Can we add front-end tests? +- [x] Can we add front-end tests? - [x] Break down oott_api.dart in modules ## Improve engine diff --git a/frontend/lib/utils/backend_reachability.dart b/frontend/lib/utils/backend_reachability.dart index 3139157..00a92fe 100644 --- a/frontend/lib/utils/backend_reachability.dart +++ b/frontend/lib/utils/backend_reachability.dart @@ -38,6 +38,16 @@ class BackendReachability extends ChangeNotifier { _prober = prober; } + /// Test-only hook to force a deterministic online state so polling widgets + /// load during tests (where connectivity_plus has no platform backing). + @visibleForTesting + void forceOnlineForTesting() { + _deviceHasNetwork = true; + _isBackendReachable = true; + _lastErrorMessage = null; + notifyListeners(); + } + void recordSuccess() { _lastSuccessAt = DateTime.now(); _lastErrorMessage = null; diff --git a/frontend/lib/utils/oott_api.dart b/frontend/lib/utils/oott_api.dart index f2a6539..788a7f2 100644 --- a/frontend/lib/utils/oott_api.dart +++ b/frontend/lib/utils/oott_api.dart @@ -2,6 +2,7 @@ library; import 'package:dio/dio.dart'; import 'package:encrypter/encrypter/xor.dart'; +import 'package:flutter/foundation.dart'; import 'api/api_error.dart'; import 'api/dio_config.dart'; @@ -45,6 +46,18 @@ class BackendAPI { BackendReachability.instance.setProber(() => _dio.get('/test')); } + /// Test-only access to the underlying [Dio] so tests can swap in a client + /// with a mock adapter installed. Mirrors [reconfigureFromPrefs]'s prober + /// wiring so reachability stays consistent after a swap. + @visibleForTesting + Dio get dioForTesting => _dio; + + @visibleForTesting + set dioForTesting(Dio dio) { + _dio = dio; + BackendReachability.instance.setProber(() => _dio.get('/test')); + } + // Returns null if the test was successful, and a String with a message about the issue if not static Future test(String baseUrl, String apiKey) async { final dio = buildDio( diff --git a/frontend/pubspec.lock b/frontend/pubspec.lock index 517259e..b09b40b 100644 --- a/frontend/pubspec.lock +++ b/frontend/pubspec.lock @@ -240,6 +240,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.6.0" + http_mock_adapter: + dependency: "direct dev" + description: + name: http_mock_adapter + sha256: "46399c78bd4a0af071978edd8c502d7aeeed73b5fb9860bca86b5ed647a63c1b" + url: "https://pub.dev" + source: hosted + version: "0.6.1" http_parser: dependency: transitive description: @@ -312,6 +320,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + logger: + dependency: transitive + description: + name: logger + sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c" + url: "https://pub.dev" + source: hosted + version: "2.7.0" logging: dependency: transitive description: diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index cfb7dda..fea650d 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -26,6 +26,7 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 + http_mock_adapter: ^0.6.1 flutter: uses-material-design: true diff --git a/frontend/run_tests.sh b/frontend/run_tests.sh new file mode 100755 index 0000000..c3283a7 --- /dev/null +++ b/frontend/run_tests.sh @@ -0,0 +1,2 @@ +#!/bin/sh +flutter test diff --git a/frontend/test/api/backend_api_test_endpoint_test.dart b/frontend/test/api/backend_api_test_endpoint_test.dart new file mode 100644 index 0000000..ae3eed5 --- /dev/null +++ b/frontend/test/api/backend_api_test_endpoint_test.dart @@ -0,0 +1,18 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frontend/utils/oott_api.dart'; + +void main() { + // BackendAPI.test() builds its own Dio internally (no injectable seam), so + // only its error-mapping path is testable here: an unresolvable host must + // come back as a non-null, user-facing message rather than throwing. The + // success path is a documented coverage gap. + test('BackendAPI.test returns a user message for an unreachable backend', + () async { + // Loopback port 1 is closed: connection is refused immediately, keeping the + // test hermetic (no DNS, no TLS, no traffic leaving the machine). + final result = await BackendAPI.test('http://127.0.0.1:1/api', ''); + + expect(result, isNotNull); + expect(result, isNotEmpty); + }); +} diff --git a/frontend/test/api/devices_api_test.dart b/frontend/test/api/devices_api_test.dart new file mode 100644 index 0000000..b45a93a --- /dev/null +++ b/frontend/test/api/devices_api_test.dart @@ -0,0 +1,182 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frontend/model/device_type.dart'; +import 'package:frontend/utils/oott_api.dart'; +import 'package:http_mock_adapter/http_mock_adapter.dart'; + +import '../helpers/backend_test_harness.dart'; +import '../helpers/fixtures.dart'; + +void main() { + late DioAdapter adapter; + + setUp(() async { + adapter = await setUpBackendForTest(); + }); + + test('getDevice fetches and decodes a single device', () async { + adapter.onGet( + '/devices/aa:bb:cc:dd:ee:ff', + (server) => server.reply(200, deviceJson(owner: 'carol')), + ); + + final device = await BackendAPI.instance.getDevice('aa:bb:cc:dd:ee:ff'); + + expect(device.owner, 'carol'); + }); + + test('getDeviceSummary GETs /devices/summary', () async { + adapter.onGet( + '/devices/summary', + (server) => server.reply(200, deviceSummaryJson(totalRegistered: 7)), + ); + + final summary = await BackendAPI.instance.getDeviceSummary(); + + expect(summary.totalRegistered, 7); + }); + + test('listDevices sends filter + pagination params and trims extra item', + () async { + adapter.onGet( + '/devices', + (server) => server.reply(200, [ + deviceJson(macAddress: '00:00:00:00:00:01'), + deviceJson(macAddress: '00:00:00:00:00:02'), + deviceJson(macAddress: '00:00:00:00:00:03'), + ]), + queryParameters: { + 'is_registered': false, + 'device_type': 'laptop', + 'sort_by': 'last_seen', + 'sort_order': 'asc', + 'page_offset': 0, + 'page_limit': 3, + }, + ); + + final result = await BackendAPI.instance.listDevices( + isRegistered: false, + deviceType: DeviceType.laptop, + sortBy: 'last_seen', + sortAscending: true, + page: 0, + perPage: 2, + ); + + expect(result.items, hasLength(2)); + expect(result.hasNextPage, isTrue); + }); + + test('listDevices reports no next page when fewer than perPage+1 returned', + () async { + adapter.onGet( + '/devices', + (server) => server.reply(200, [deviceJson()]), + ); + + final result = await BackendAPI.instance.listDevices(perPage: 2); + + expect(result.items, hasLength(1)); + expect(result.hasNextPage, isFalse); + }); + + test('listDevices maps the unknown device type to an empty filter', () async { + adapter.onGet( + '/devices', + (server) => server.reply(200, []), + queryParameters: { + 'device_type': '', + 'page_offset': 0, + 'page_limit': 11, + }, + ); + + final result = await BackendAPI.instance.listDevices( + deviceType: DeviceType.unknown, + ); + + expect(result.items, isEmpty); + }); + + test('registerDevice PUTs the device payload', () async { + adapter.onPut( + '/devices', + (server) => server.reply(200, null), + data: { + 'mac_address': 'aa:bb:cc:dd:ee:ff', + 'owner': 'dave', + 'device_type': 'phone', + 'name': 'Dave Phone', + }, + ); + + await BackendAPI.instance.registerDevice( + 'aa:bb:cc:dd:ee:ff', + 'dave', + 'phone', + name: 'Dave Phone', + ); + }); + + test('updateDevice PUTs to the device path', () async { + adapter.onPut( + '/devices/aa:bb:cc:dd:ee:ff', + (server) => server.reply(200, null), + data: { + 'owner': 'erin', + 'device_type': 'tv', + 'vendor': 'Globex', + 'name': null, + }, + ); + + await BackendAPI.instance.updateDevice( + 'aa:bb:cc:dd:ee:ff', + 'erin', + 'tv', + 'Globex', + ); + }); + + test('forgetDevice DELETEs the device path', () async { + adapter.onDelete( + '/devices/aa:bb:cc:dd:ee:ff', + (server) => server.reply(200, null), + ); + + await BackendAPI.instance.forgetDevice('aa:bb:cc:dd:ee:ff'); + }); + + test('getDeviceEvents decodes a list of events', () async { + adapter.onGet( + '/devices/aa:bb:cc:dd:ee:ff/events', + (server) => server.reply(200, [ + deviceEventJson(id: 1, scanner: 'Arp'), + deviceEventJson(id: 2, scanner: 'Mdns'), + ]), + ); + + final events = await BackendAPI.instance.getDeviceEvents( + 'aa:bb:cc:dd:ee:ff', + ); + + expect(events, hasLength(2)); + expect(events.first.scannerLabel, 'ARP'); + }); + + test('getDeviceEvents sends created_from when provided', () async { + final from = DateTime.utc(2026, 5, 1, 0, 0, 0); + adapter.onGet( + '/devices/aa:bb:cc:dd:ee:ff/events', + (server) => server.reply(200, []), + queryParameters: {'created_from': from.toIso8601String()}, + ); + + final events = await BackendAPI.instance.getDeviceEvents( + 'aa:bb:cc:dd:ee:ff', + createdFrom: from, + ); + + expect(events, isEmpty); + }); +} diff --git a/frontend/test/api/notifications_api_test.dart b/frontend/test/api/notifications_api_test.dart new file mode 100644 index 0000000..a11987f --- /dev/null +++ b/frontend/test/api/notifications_api_test.dart @@ -0,0 +1,89 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frontend/utils/oott_api.dart'; +import 'package:http_mock_adapter/http_mock_adapter.dart'; + +import '../helpers/backend_test_harness.dart'; +import '../helpers/fixtures.dart'; + +void main() { + late DioAdapter adapter; + + setUp(() async { + adapter = await setUpBackendForTest(); + }); + + test('markNotificationAsRead GETs the notification path', () async { + adapter.onGet( + '/notifications/7', + (server) => server.reply(200, null), + ); + + await BackendAPI.instance.markNotificationAsRead(7); + }); + + test('markNotificationAsNew POSTs to mark_as_new', () async { + adapter.onPost( + '/notifications/7/mark_as_new', + (server) => server.reply(200, null), + ); + + await BackendAPI.instance.markNotificationAsNew(7); + }); + + test('markAllNotificationsAsRead POSTs to mark_all_as_old', () async { + adapter.onPost( + '/notifications/mark_all_as_old', + (server) => server.reply(200, null), + ); + + await BackendAPI.instance.markAllNotificationsAsRead(); + }); + + test('listNotifications sends is_new + pagination and trims extra item', + () async { + adapter.onGet( + '/notifications', + (server) => server.reply(200, [ + notificationJson(id: 1), + notificationJson(id: 2), + notificationJson(id: 3), + ]), + queryParameters: { + 'is_new': true, + 'page_offset': 0, + 'page_limit': 3, + }, + ); + + final result = await BackendAPI.instance.listNotifications( + true, + page: 0, + perPage: 2, + ); + + expect(result.items, hasLength(2)); + expect(result.hasNextPage, isTrue); + }); + + test('listNotifications maps a null filter to an empty is_new param', + () async { + adapter.onGet( + '/notifications', + (server) => server.reply(200, []), + queryParameters: { + 'is_new': '', + 'page_offset': 10, + 'page_limit': 6, + }, + ); + + final result = await BackendAPI.instance.listNotifications( + null, + page: 2, + perPage: 5, + ); + + expect(result.items, isEmpty); + expect(result.hasNextPage, isFalse); + }); +} diff --git a/frontend/test/api/scanners_api_test.dart b/frontend/test/api/scanners_api_test.dart new file mode 100644 index 0000000..1be7fb5 --- /dev/null +++ b/frontend/test/api/scanners_api_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frontend/utils/oott_api.dart'; +import 'package:http_mock_adapter/http_mock_adapter.dart'; + +import '../helpers/backend_test_harness.dart'; +import '../helpers/fixtures.dart'; + +void main() { + late DioAdapter adapter; + + setUp(() async { + adapter = await setUpBackendForTest(); + }); + + test('getArpScannerStatus decodes /arp_scanner/status', () async { + adapter.onGet( + '/arp_scanner/status', + (server) => server.reply( + 200, + intervalScannerJson(isRunning: true, runningForSeconds: 12), + ), + ); + + final status = await BackendAPI.instance.getArpScannerStatus(); + + expect(status.isRunning, isTrue); + expect(status.runningForSeconds, 12.0); + }); + + test('getSnmpScannerStatus decodes /snmp_scanner/status', () async { + adapter.onGet( + '/snmp_scanner/status', + (server) => server.reply(200, intervalScannerJson(isRunning: false)), + ); + + final status = await BackendAPI.instance.getSnmpScannerStatus(); + + expect(status.isRunning, isFalse); + }); + + test('getMdnsScannerStatus decodes /mdns_scanner/status', () async { + adapter.onGet( + '/mdns_scanner/status', + (server) => server.reply( + 200, + listenerScannerJson(isListening: true, devicesSeen: 3), + ), + ); + + final status = await BackendAPI.instance.getMdnsScannerStatus(); + + expect(status.isListening, isTrue); + expect(status.devicesSeen, 3); + }); + + test('getSsdpScannerStatus decodes /ssdp_scanner/status', () async { + adapter.onGet( + '/ssdp_scanner/status', + (server) => server.reply(200, listenerScannerJson(devicesSeen: 1)), + ); + + final status = await BackendAPI.instance.getSsdpScannerStatus(); + + expect(status.devicesSeen, 1); + }); + + test('getDhcpScannerStatus decodes /dhcp_scanner/status', () async { + adapter.onGet( + '/dhcp_scanner/status', + (server) => server.reply( + 200, + listenerScannerJson(isListening: true, devicesSeen: 5), + ), + ); + + final status = await BackendAPI.instance.getDhcpScannerStatus(); + + expect(status.isListening, isTrue); + expect(status.devicesSeen, 5); + }); +} diff --git a/frontend/test/flutter_test_config.dart b/frontend/test/flutter_test_config.dart new file mode 100644 index 0000000..e38d86b --- /dev/null +++ b/frontend/test/flutter_test_config.dart @@ -0,0 +1,12 @@ +import 'dart:async'; + +import 'package:google_fonts/google_fonts.dart'; + +/// Global test bootstrap, picked up automatically by `flutter test` for every +/// test under `test/`. Disables Google Fonts runtime fetching so no test can +/// accidentally hit the network for a font (tests render screens directly +/// rather than through the app shell, but this is a cheap safety net). +Future testExecutable(FutureOr Function() testMain) async { + GoogleFonts.config.allowRuntimeFetching = false; + await testMain(); +} diff --git a/frontend/test/helpers/backend_test_harness.dart b/frontend/test/helpers/backend_test_harness.dart new file mode 100644 index 0000000..b2aaae1 --- /dev/null +++ b/frontend/test/helpers/backend_test_harness.dart @@ -0,0 +1,67 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:frontend/utils/backend_reachability.dart'; +import 'package:frontend/utils/oott_api.dart'; +import 'package:frontend/utils/pref_utils.dart'; +import 'package:http_mock_adapter/http_mock_adapter.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const _connectivityMethodChannel = MethodChannel( + 'dev.fluttercommunity.plus/connectivity', +); +const _connectivityEventChannel = EventChannel( + 'dev.fluttercommunity.plus/connectivity_status', +); + +bool _channelsStubbed = false; + +/// Neutralises the connectivity_plus platform channels so the +/// [BackendReachability] singleton can be constructed in tests without the +/// plugin throwing `MissingPluginException`. `check` reports a network, and +/// the change stream stays open but emits nothing. +void _stubConnectivityChannels() { + if (_channelsStubbed) return; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(_connectivityMethodChannel, (call) async { + if (call.method == 'check') return ['wifi']; + return null; + }); + messenger.setMockStreamHandler( + _connectivityEventChannel, + MockStreamHandler.inline(onListen: (arguments, events) {}), + ); + _channelsStubbed = true; +} + +/// Prepares the backend singletons for a test and returns a [DioAdapter] so the +/// test can stub HTTP routes. Call from `setUp`. +/// +/// This (1) seeds mock SharedPreferences, (2) stubs the connectivity channels, +/// (3) forces [BackendReachability] online so polling widgets load, and +/// (4) swaps the [BackendAPI] singleton's Dio for one backed by a mock adapter. +/// +/// `prefs` are only honoured on the first call within a test isolate, because +/// `PrefUtil` caches the SharedPreferences instance after its one-shot init. +/// Use [PrefUtil.setValue] for per-test changes after that. +Future setUpBackendForTest({ + Map prefs = const {}, +}) async { + TestWidgetsFlutterBinding.ensureInitialized(); + _stubConnectivityChannels(); + + SharedPreferences.setMockInitialValues({ + 'base_url': 'http://test.local/api', + 'api_key': '', + ...prefs, + }); + await PrefUtil.init(); + + BackendReachability.instance.forceOnlineForTesting(); + + final dio = Dio(BaseOptions(baseUrl: 'http://test.local/api')); + final adapter = DioAdapter(dio: dio); + BackendAPI.instance.dioForTesting = dio; + return adapter; +} diff --git a/frontend/test/helpers/fixtures.dart b/frontend/test/helpers/fixtures.dart new file mode 100644 index 0000000..fb7f536 --- /dev/null +++ b/frontend/test/helpers/fixtures.dart @@ -0,0 +1,103 @@ +/// Typed JSON builders mirroring the backend response shapes, so route stubs +/// and model tests stay terse and use the exact snake_case keys the app's +/// `fromJson` constructors read. Every field has a sensible default; override +/// only what a given test cares about. +library; + +Map deviceJson({ + String macAddress = 'aa:bb:cc:dd:ee:ff', + String ipv4Address = '192.168.0.10', + String vendor = 'Acme Corp', + String lastSeen = '2026-06-01T12:00:00Z', + bool isRegistered = false, + String owner = 'alice', + String deviceType = 'laptop', + String? name, +}) => { + 'mac_address': macAddress, + 'ipv4_address': ipv4Address, + 'vendor': vendor, + 'last_seen': lastSeen, + 'is_registered': isRegistered, + 'owner': owner, + 'device_type': deviceType, + 'name': name, +}; + +Map deviceSummaryJson({ + int totalRegistered = 0, + int seenLastDayRegistered = 0, + int seenLastDayUnregistered = 0, + int seenLastWeekRegistered = 0, + int seenLastWeekUnregistered = 0, +}) => { + 'total_registered': totalRegistered, + 'seen_last_day_registered': seenLastDayRegistered, + 'seen_last_day_unregistered': seenLastDayUnregistered, + 'seen_last_week_registered': seenLastWeekRegistered, + 'seen_last_week_unregistered': seenLastWeekUnregistered, +}; + +Map deviceEventJson({ + int id = 1, + String macAddress = 'aa:bb:cc:dd:ee:ff', + String createdOn = '2026-06-01T12:00:00Z', + String eventType = 'seen', + String ipv4Address = '192.168.0.10', + String vendor = 'Acme Corp', + String scanner = 'Arp', +}) => { + 'id': id, + 'mac_address': macAddress, + 'created_on': createdOn, + 'event_type': eventType, + 'ipv4_address': ipv4Address, + 'vendor': vendor, + 'scanner': scanner, +}; + +Map notificationJson({ + int id = 1, + String createdOn = '2026-06-01T12:00:00Z', + String notificationType = 'newDeviceFound', + String title = 'New device found', + String body = 'A new device joined the network', + bool isNew = true, + String? macAddress = 'aa:bb:cc:dd:ee:ff', +}) => { + 'id': id, + 'created_on': createdOn, + 'notification_type': notificationType, + 'title': title, + 'body': body, + 'is_new': isNew, + 'mac_address': macAddress, +}; + +/// Shape shared by the ARP and SNMP scanners (run-on-interval scanners). +Map intervalScannerJson({ + bool isRunning = false, + double? runningForSeconds, + double? nextRunInSeconds, + int? lastScanDevicesSeen, + double? lastScanSecondsAgo, +}) => { + 'is_running': isRunning, + 'running_for_seconds': runningForSeconds, + 'next_run_in_seconds': nextRunInSeconds, + 'last_scan_devices_seen': lastScanDevicesSeen, + 'last_scan_seconds_ago': lastScanSecondsAgo, +}; + +/// Shape shared by the mDNS, SSDP and DHCP scanners (passive listeners). +Map listenerScannerJson({ + bool isListening = false, + double? listeningForSeconds, + int devicesSeen = 0, + double? lastDeviceSeenSecondsAgo, +}) => { + 'is_listening': isListening, + 'listening_for_seconds': listeningForSeconds, + 'devices_seen': devicesSeen, + 'last_device_seen_seconds_ago': lastDeviceSeenSecondsAgo, +}; diff --git a/frontend/test/helpers/pump_app.dart b/frontend/test/helpers/pump_app.dart new file mode 100644 index 0000000..0678942 --- /dev/null +++ b/frontend/test/helpers/pump_app.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:frontend/main.dart'; +import 'package:frontend/theme/catppuccin_mocha_theme.dart'; +import 'package:provider/provider.dart'; + +/// Pumps a single screen/widget under test inside the minimal scaffolding the +/// app's widgets expect: an [AppState] provider (read by Settings) and a +/// [MaterialApp] carrying a real app theme. The real theme is required because +/// several widgets read `Theme.of(context).extension()!` +/// non-null and would crash under a bare [ThemeData]. +/// +/// Widgets are pumped directly (never through the router shell) so the AppBar's +/// Google Fonts lookup is never reached. +Future pumpScreen( + WidgetTester tester, + Widget child, { + Size size = const Size(900, 1200), +}) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + ChangeNotifierProvider( + create: (_) => AppState(), + child: MaterialApp( + theme: catppuccinMochaDarkTheme, + home: Scaffold(body: child), + ), + ), + ); +} + +/// Pumps frames (advancing fake time in small steps) until [finder] matches or +/// [maxFrames] is reached. Needed because Dio resolves each request through an +/// internal zero-duration timer, so the response only lands once the fake clock +/// is advanced — `pumpAndSettle` is unusable here as the polling widgets keep a +/// periodic timer alive forever. +Future pumpUntilFound( + WidgetTester tester, + Finder finder, { + int maxFrames = 40, + Duration step = const Duration(milliseconds: 10), +}) async { + for (var i = 0; i < maxFrames; i++) { + if (finder.evaluate().isNotEmpty) return; + await tester.pump(step); + } + if (finder.evaluate().isEmpty) { + fail('Timed out waiting for $finder'); + } +} + +/// Unmounts the widget tree so widgets' `dispose()` cancel their timers and the +/// test ends without "pending timer" failures. Call at the end of widget tests +/// that exercise polling widgets. +Future tearDownTree(WidgetTester tester) => + tester.pumpWidget(const SizedBox()); diff --git a/frontend/test/unit/api_error_test.dart b/frontend/test/unit/api_error_test.dart new file mode 100644 index 0000000..fc75b97 --- /dev/null +++ b/frontend/test/unit/api_error_test.dart @@ -0,0 +1,86 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:frontend/utils/api/api_error.dart'; + +void main() { + final options = RequestOptions(path: '/x'); + + DioException dio(DioExceptionType type, {int? status}) => DioException( + requestOptions: options, + type: type, + response: status == null + ? null + : Response(requestOptions: options, statusCode: status), + ); + + test('maps shape errors', () { + expect( + dioErrorToUserMessage(TypeError()), + 'Unexpected response shape from backend.', + ); + expect( + dioErrorToUserMessage(const FormatException('bad')), + 'Unexpected response shape from backend.', + ); + }); + + test('maps non-Dio errors to a generic message', () { + expect(dioErrorToUserMessage(Exception('boom')), 'Unexpected error.'); + }); + + test('maps timeouts', () { + expect( + dioErrorToUserMessage(dio(DioExceptionType.connectionTimeout)), + 'Backend did not respond in time.', + ); + expect( + dioErrorToUserMessage(dio(DioExceptionType.receiveTimeout)), + 'Backend did not respond in time.', + ); + }); + + test('maps connection and certificate errors', () { + expect( + dioErrorToUserMessage(dio(DioExceptionType.connectionError)), + 'Cannot reach backend. Check the base URL and your network.', + ); + expect( + dioErrorToUserMessage(dio(DioExceptionType.badCertificate)), + 'Backend TLS certificate could not be verified.', + ); + }); + + test('maps bad responses by status code', () { + expect( + dioErrorToUserMessage(dio(DioExceptionType.badResponse, status: 401)), + 'Authentication failed. Check your API key in Settings.', + ); + expect( + dioErrorToUserMessage(dio(DioExceptionType.badResponse, status: 403)), + 'Authentication failed. Check your API key in Settings.', + ); + expect( + dioErrorToUserMessage(dio(DioExceptionType.badResponse, status: 404)), + 'Not found.', + ); + expect( + dioErrorToUserMessage(dio(DioExceptionType.badResponse, status: 503)), + 'Backend error (status 503). Please try again.', + ); + expect( + dioErrorToUserMessage(dio(DioExceptionType.badResponse, status: 418)), + 'Unexpected response from backend (status 418).', + ); + }); + + test('maps cancellation and unknown errors', () { + expect( + dioErrorToUserMessage(dio(DioExceptionType.cancel)), + 'Request canceled.', + ); + expect( + dioErrorToUserMessage(dio(DioExceptionType.unknown)), + 'Unexpected error contacting backend.', + ); + }); +} diff --git a/frontend/test/unit/device_event_test.dart b/frontend/test/unit/device_event_test.dart new file mode 100644 index 0000000..be1f824 --- /dev/null +++ b/frontend/test/unit/device_event_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frontend/model/device_event.dart'; + +import '../helpers/fixtures.dart'; + +void main() { + test('DeviceEvent.fromJson maps all fields', () { + final event = DeviceEvent.fromJson( + deviceEventJson( + id: 42, + macAddress: '11:22:33:44:55:66', + createdOn: '2026-05-20T10:30:00Z', + eventType: 'changed', + ipv4Address: '10.0.0.9', + vendor: 'Initech', + scanner: 'Ssdp', + ), + ); + + expect(event.id, 42); + expect(event.macAddress, '11:22:33:44:55:66'); + expect(event.createdOn, DateTime.parse('2026-05-20T10:30:00Z')); + expect(event.eventType, 'changed'); + expect(event.ipv4Address, '10.0.0.9'); + expect(event.vendor, 'Initech'); + expect(event.scanner, 'Ssdp'); + }); + + test('scannerLabel humanises known scanner names', () { + expect(_label('Arp'), 'ARP'); + expect(_label('Mdns'), 'mDNS'); + expect(_label('Ssdp'), 'SSDP/UPnP'); + expect(_label('Dhcp'), 'DHCP'); + expect(_label('Snmp'), 'SNMP'); + }); + + test('scannerLabel passes through unknown scanner names', () { + expect(_label('Custom'), 'Custom'); + }); +} + +String _label(String scanner) => + DeviceEvent.fromJson(deviceEventJson(scanner: scanner)).scannerLabel; diff --git a/frontend/test/unit/device_summary_test.dart b/frontend/test/unit/device_summary_test.dart new file mode 100644 index 0000000..83039e1 --- /dev/null +++ b/frontend/test/unit/device_summary_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frontend/model/device_summary.dart'; + +import '../helpers/fixtures.dart'; + +void main() { + test('DeviceSummary.fromJson maps every count field', () { + final summary = DeviceSummary.fromJson( + deviceSummaryJson( + totalRegistered: 10, + seenLastDayRegistered: 4, + seenLastDayUnregistered: 2, + seenLastWeekRegistered: 8, + seenLastWeekUnregistered: 5, + ), + ); + + expect(summary.totalRegistered, 10); + expect(summary.seenLastDayRegistered, 4); + expect(summary.seenLastDayUnregistered, 2); + expect(summary.seenLastWeekRegistered, 8); + expect(summary.seenLastWeekUnregistered, 5); + }); +} diff --git a/frontend/test/unit/device_test.dart b/frontend/test/unit/device_test.dart new file mode 100644 index 0000000..7dad422 --- /dev/null +++ b/frontend/test/unit/device_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frontend/model/device.dart'; +import 'package:frontend/model/device_type.dart'; + +import '../helpers/fixtures.dart'; + +void main() { + group('Device.fromJson', () { + test('maps all snake_case fields', () { + final device = Device.fromJson( + deviceJson( + macAddress: '11:22:33:44:55:66', + ipv4Address: '10.0.0.5', + vendor: 'Globex', + lastSeen: '2026-05-30T08:15:00Z', + isRegistered: true, + owner: 'bob', + deviceType: 'network_appliance', + name: 'Router', + ), + ); + + expect(device.macAddress, '11:22:33:44:55:66'); + expect(device.ipv4Address, '10.0.0.5'); + expect(device.vendor, 'Globex'); + expect(device.lastSeen, DateTime.parse('2026-05-30T08:15:00Z')); + expect(device.isRegistered, isTrue); + expect(device.owner, 'bob'); + expect(device.deviceType, DeviceType.networkAppliance); + expect(device.name, 'Router'); + }); + + test('leaves name null when absent', () { + final device = Device.fromJson(deviceJson()); + expect(device.name, isNull); + }); + + test('falls back to unknown device type for unrecognised values', () { + final device = Device.fromJson(deviceJson(deviceType: 'spaceship')); + expect(device.deviceType, DeviceType.unknown); + }); + }); +} diff --git a/frontend/test/unit/device_type_test.dart b/frontend/test/unit/device_type_test.dart new file mode 100644 index 0000000..86c4b26 --- /dev/null +++ b/frontend/test/unit/device_type_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frontend/model/device_type.dart'; + +void main() { + test('fromString parses every snake_case API name', () { + expect(DeviceType.fromString('phone'), DeviceType.phone); + expect(DeviceType.fromString('network_appliance'), DeviceType.networkAppliance); + expect(DeviceType.fromString('home_security'), DeviceType.homeSecurity); + expect(DeviceType.fromString('home_appliance'), DeviceType.homeAppliance); + expect(DeviceType.fromString('gaming_console'), DeviceType.gamingConsole); + }); + + test('fromString is case-insensitive', () { + expect(DeviceType.fromString('TV'), DeviceType.tv); + }); + + test('fromString falls back to unknown', () { + expect(DeviceType.fromString('toaster'), DeviceType.unknown); + }); + + test('apiName mirrors the snake_case backend identifiers', () { + expect(DeviceType.networkAppliance.apiName, 'network_appliance'); + expect(DeviceType.gamingConsole.apiName, 'gaming_console'); + expect(DeviceType.phone.apiName, 'phone'); + }); + + test('apiName round-trips through fromString', () { + for (final type in DeviceType.values) { + if (type == DeviceType.unknown) continue; + expect(DeviceType.fromString(type.apiName), type); + } + }); + + test('label special-cases acronyms', () { + expect(DeviceType.tv.label, 'TV'); + expect(DeviceType.pc.label, 'PC'); + expect(DeviceType.networkAppliance.label, 'Network Appliance'); + }); +} diff --git a/frontend/test/unit/duration_formatter_test.dart b/frontend/test/unit/duration_formatter_test.dart new file mode 100644 index 0000000..676fa54 --- /dev/null +++ b/frontend/test/unit/duration_formatter_test.dart @@ -0,0 +1,27 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frontend/utils/duration_formatter.dart'; + +void main() { + test('renders sub-minute values in seconds', () { + expect(formatSeconds(0), '0s'); + expect(formatSeconds(59), '59s'); + }); + + test('rounds fractional seconds to the nearest whole second', () { + expect(formatSeconds(5.4), '5s'); + expect(formatSeconds(5.6), '6s'); + }); + + test('renders minute-and-second values past 60 seconds', () { + expect(formatSeconds(60), '1m 0s'); + expect(formatSeconds(125), '2m 5s'); + }); + + test('rounding up to a full minute carries into the minutes part', () { + expect(formatSeconds(59.6), '1m 0s'); + }); + + test('clamps negative values to zero', () { + expect(formatSeconds(-10), '0s'); + }); +} diff --git a/frontend/test/unit/friendly_date_formatter_test.dart b/frontend/test/unit/friendly_date_formatter_test.dart new file mode 100644 index 0000000..b8caa5c --- /dev/null +++ b/frontend/test/unit/friendly_date_formatter_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frontend/utils/friendly_date_formatter.dart'; + +void main() { + final formatter = FriendlyDateFormatter(); + final now = DateTime.now(); + + test('renders very recent times as "Just now"', () { + expect(formatter.format(now.subtract(const Duration(seconds: 30))), 'Just now'); + expect(formatter.format(now.subtract(const Duration(minutes: 2))), 'Just now'); + }); + + test('renders times under an hour as relative minutes', () { + expect( + formatter.format(now.subtract(const Duration(minutes: 30))), + '30 minutes ago', + ); + }); + + test('renders earlier-today / late-yesterday times with a clock time', () { + final candidate = now.subtract(const Duration(hours: 2)); + final sameDay = candidate.year == now.year && + candidate.month == now.month && + candidate.day == now.day; + + expect( + formatter.format(candidate), + startsWith(sameDay ? 'Today at' : 'Yesterday at'), + ); + }); + + test('renders a yesterday-noon time as "Yesterday at"', () { + final yesterdayNoon = + DateTime(now.year, now.month, now.day - 1, 12, 0); + + expect(formatter.format(yesterdayNoon), startsWith('Yesterday at')); + }); + + test('renders older dates with the full date', () { + final threeDaysAgo = DateTime(now.year, now.month, now.day - 3, 12, 0); + final result = formatter.format(threeDaysAgo); + + expect(result, contains(threeDaysAgo.year.toString())); + expect(result, isNot(startsWith('Today'))); + expect(result, isNot(startsWith('Yesterday'))); + }); +} diff --git a/frontend/test/unit/notification_test.dart b/frontend/test/unit/notification_test.dart new file mode 100644 index 0000000..5912f95 --- /dev/null +++ b/frontend/test/unit/notification_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frontend/model/notification.dart'; +import 'package:frontend/model/notification_type.dart'; + +import '../helpers/fixtures.dart'; + +void main() { + group('Notification.fromJson', () { + test('maps all fields', () { + final notification = Notification.fromJson( + notificationJson( + id: 9, + createdOn: '2026-05-15T09:00:00Z', + notificationType: 'deviceChanged', + title: 'Device changed', + body: 'Something changed', + isNew: false, + macAddress: '11:22:33:44:55:66', + ), + ); + + expect(notification.id, 9); + expect(notification.createdOn, DateTime.parse('2026-05-15T09:00:00Z')); + expect(notification.notificationType, NotificationType.deviceChanged); + expect(notification.title, 'Device changed'); + expect(notification.body, 'Something changed'); + expect(notification.isNew, isFalse); + expect(notification.macAddress, '11:22:33:44:55:66'); + }); + + test('leaves macAddress null when absent', () { + final notification = Notification.fromJson( + notificationJson(macAddress: null), + ); + expect(notification.macAddress, isNull); + }); + }); + + test('toJson round-trips back through fromJson', () { + final original = Notification.fromJson(notificationJson(id: 3)); + final restored = Notification.fromJson(original.toJson()); + + expect(restored.id, original.id); + expect(restored.title, original.title); + expect(restored.body, original.body); + expect(restored.isNew, original.isNew); + expect(restored.notificationType, original.notificationType); + expect(restored.createdOn, original.createdOn); + expect(restored.macAddress, original.macAddress); + }); + + test('copyWith only overrides isNew', () { + final original = Notification.fromJson(notificationJson(isNew: true)); + final updated = original.copyWith(isNew: false); + + expect(updated.isNew, isFalse); + expect(updated.id, original.id); + expect(updated.title, original.title); + expect(original.isNew, isTrue, reason: 'original is not mutated'); + }); +} diff --git a/frontend/test/unit/notification_type_test.dart b/frontend/test/unit/notification_type_test.dart new file mode 100644 index 0000000..3092fa5 --- /dev/null +++ b/frontend/test/unit/notification_type_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frontend/model/notification_type.dart'; + +void main() { + test('fromString parses known types case-insensitively', () { + expect( + NotificationType.fromString('newDeviceFound'), + NotificationType.newDeviceFound, + ); + expect( + NotificationType.fromString('deviceonlineaftertime'), + NotificationType.deviceOnlineAfterTime, + ); + expect( + NotificationType.fromString('DeviceChanged'), + NotificationType.deviceChanged, + ); + }); + + test('fromString falls back to other', () { + expect(NotificationType.fromString('whatever'), NotificationType.other); + }); + + test('name round-trips through fromString', () { + for (final type in NotificationType.values) { + if (type == NotificationType.other) continue; + expect(NotificationType.fromString(type.name), type); + } + }); +} diff --git a/frontend/test/unit/polled_value_test.dart b/frontend/test/unit/polled_value_test.dart new file mode 100644 index 0000000..1ff29af --- /dev/null +++ b/frontend/test/unit/polled_value_test.dart @@ -0,0 +1,134 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:frontend/utils/backend_reachability.dart'; +import 'package:frontend/utils/polled_value.dart'; + +import '../helpers/backend_test_harness.dart'; + +void main() { + late List> completers; + + setUp(() async { + completers = []; + await setUpBackendForTest(); + }); + + // A PolledValue whose every fetch is resolved manually through [completers]. + // The poll interval is effectively disabled so only explicit reloads fetch. + PolledValue makePolled({ + Duration staleErrorAfter = const Duration(seconds: 30), + }) { + return PolledValue( + fetch: ({cancelToken}) { + final completer = Completer(); + completers.add(completer); + return completer.future; + }, + pollInterval: const Duration(hours: 1), + staleErrorAfter: staleErrorAfter, + ); + } + + DioException connectionError() => DioException( + requestOptions: RequestOptions(path: '/x'), + type: DioExceptionType.connectionError, + ); + + test('starts in initialLoading then becomes fresh on success', () async { + final polled = makePolled(); + addTearDown(polled.dispose); + + expect(polled.freshness, PolledFreshness.initialLoading); + expect(polled.value, isNull); + + completers[0].complete(5); + await pumpEventQueue(); + + expect(polled.freshness, PolledFreshness.fresh); + expect(polled.value, 5); + }); + + test('becomes error when the first load fails with no prior value', () async { + final polled = makePolled(); + addTearDown(polled.dispose); + + completers[0].completeError(connectionError()); + await pumpEventQueue(); + + expect(polled.freshness, PolledFreshness.error); + expect(polled.value, isNull); + expect(polled.lastErrorMessage, isNotNull); + }); + + test('a failure after a success is stale, then error past staleErrorAfter', + () async { + final polled = makePolled(staleErrorAfter: const Duration(seconds: 1)); + addTearDown(polled.dispose); + + completers[0].complete(1); + await pumpEventQueue(); + expect(polled.freshness, PolledFreshness.fresh); + + // Force a second load and fail it. + polled.pause(); + polled.resume(); + await pumpEventQueue(); + completers[1].completeError(connectionError()); + await pumpEventQueue(); + + expect(polled.value, 1, reason: 'keeps the last good value'); + expect(polled.freshness, PolledFreshness.stale); + + await Future.delayed(const Duration(milliseconds: 1200)); + expect(polled.freshness, PolledFreshness.error); + }); + + test('effectiveFreshness downgrades error to stale while offline', () async { + final polled = makePolled(staleErrorAfter: const Duration(milliseconds: 1)); + addTearDown(polled.dispose); + + completers[0].complete(1); + await pumpEventQueue(); + polled.pause(); + polled.resume(); + await pumpEventQueue(); + completers[1].completeError(connectionError()); + await pumpEventQueue(); + await Future.delayed(const Duration(milliseconds: 5)); + + // Base freshness is error (value present, but past the 1ms threshold). + expect(polled.freshness, PolledFreshness.error); + + // Online: effective freshness mirrors the base error. + expect(effectiveFreshness(polled), PolledFreshness.error); + + // Offline with a value present: downgraded to stale. + BackendReachability.instance.recordFailure(connectionError()); + expect(BackendReachability.instance.isOnline, isFalse); + expect(effectiveFreshness(polled), PolledFreshness.stale); + }); + + test('pause/resume is reference counted across reasons', () async { + final polled = makePolled(); + addTearDown(polled.dispose); + + completers[0].complete(1); + await pumpEventQueue(); + expect(completers, hasLength(1)); + + polled.pauseFor(PolledPauseReason.manual); + polled.pauseFor(PolledPauseReason.unreachable); + + // Removing only one of two reasons must not reload. + polled.resumeFor(PolledPauseReason.manual); + await pumpEventQueue(); + expect(completers, hasLength(1)); + + // Clearing the last reason reloads. + polled.resumeFor(PolledPauseReason.unreachable); + await pumpEventQueue(); + expect(completers, hasLength(2)); + }); +} diff --git a/frontend/test/unit/scanner_status_models_test.dart b/frontend/test/unit/scanner_status_models_test.dart new file mode 100644 index 0000000..c53c1dc --- /dev/null +++ b/frontend/test/unit/scanner_status_models_test.dart @@ -0,0 +1,87 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frontend/model/arp_scanner_status.dart'; +import 'package:frontend/model/dhcp_scanner_status.dart'; +import 'package:frontend/model/mdns_scanner_status.dart'; +import 'package:frontend/model/snmp_scanner_status.dart'; +import 'package:frontend/model/ssdp_scanner_status.dart'; + +import '../helpers/fixtures.dart'; + +void main() { + group('interval scanners (ARP, SNMP)', () { + test('ArpScannerStatus.fromJson maps populated fields', () { + final status = ArpScannerStatus.fromJson( + intervalScannerJson( + isRunning: true, + runningForSeconds: 12.5, + nextRunInSeconds: 30, + lastScanDevicesSeen: 7, + lastScanSecondsAgo: 5, + ), + ); + + expect(status.isRunning, isTrue); + expect(status.runningForSeconds, 12.5); + expect(status.nextRunInSeconds, 30.0); + expect(status.lastScanDevicesSeen, 7); + expect(status.lastScanSecondsAgo, 5.0); + }); + + test('ArpScannerStatus.fromJson tolerates null optionals', () { + final status = ArpScannerStatus.fromJson(intervalScannerJson()); + + expect(status.isRunning, isFalse); + expect(status.runningForSeconds, isNull); + expect(status.nextRunInSeconds, isNull); + expect(status.lastScanDevicesSeen, isNull); + }); + + test('SnmpScannerStatus.fromJson maps fields', () { + final status = SnmpScannerStatus.fromJson( + intervalScannerJson(isRunning: true, runningForSeconds: 3), + ); + + expect(status.isRunning, isTrue); + expect(status.runningForSeconds, 3.0); + }); + }); + + group('listener scanners (mDNS, SSDP, DHCP)', () { + test('MdnsScannerStatus.fromJson maps populated fields', () { + final status = MdnsScannerStatus.fromJson( + listenerScannerJson( + isListening: true, + listeningForSeconds: 99.0, + devicesSeen: 4, + lastDeviceSeenSecondsAgo: 8, + ), + ); + + expect(status.isListening, isTrue); + expect(status.listeningForSeconds, 99.0); + expect(status.devicesSeen, 4); + expect(status.lastDeviceSeenSecondsAgo, 8.0); + }); + + test('SsdpScannerStatus.fromJson requires devicesSeen and tolerates nulls', + () { + final status = SsdpScannerStatus.fromJson( + listenerScannerJson(devicesSeen: 0), + ); + + expect(status.isListening, isFalse); + expect(status.devicesSeen, 0); + expect(status.listeningForSeconds, isNull); + expect(status.lastDeviceSeenSecondsAgo, isNull); + }); + + test('DhcpScannerStatus.fromJson maps fields', () { + final status = DhcpScannerStatus.fromJson( + listenerScannerJson(isListening: true, devicesSeen: 2), + ); + + expect(status.isListening, isTrue); + expect(status.devicesSeen, 2); + }); + }); +} diff --git a/frontend/test/widget/about_test.dart b/frontend/test/widget/about_test.dart new file mode 100644 index 0000000..4adb447 --- /dev/null +++ b/frontend/test/widget/about_test.dart @@ -0,0 +1,25 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frontend/about/about.dart'; + +import '../helpers/backend_test_harness.dart'; +import '../helpers/pump_app.dart'; + +void main() { + setUp(() async { + await setUpBackendForTest(); + }); + + testWidgets('renders the about content and release fallback', (tester) async { + await pumpScreen(tester, const About()); + // PackageInfo.fromPlatform() has no plugin in tests, so the FutureBuilder + // falls back to the hard-coded release date. + await tester.pump(); + + expect(find.text('About OOTT'), findsOneWidget); + // Assert the fallback prefix only, not the release date literal (it changes + // every release). + expect(find.textContaining('Released'), findsOneWidget); + expect(find.text('Source code'), findsOneWidget); + expect(find.text('openssl'), findsOneWidget); + }); +} diff --git a/frontend/test/widget/device_list_test.dart b/frontend/test/widget/device_list_test.dart new file mode 100644 index 0000000..77c6b9d --- /dev/null +++ b/frontend/test/widget/device_list_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frontend/devices/device_list.dart'; +import 'package:frontend/devices/device_list_rows.dart'; +import 'package:http_mock_adapter/http_mock_adapter.dart'; + +import '../helpers/backend_test_harness.dart'; +import '../helpers/fixtures.dart'; +import '../helpers/pump_app.dart'; + +void main() { + late DioAdapter adapter; + + setUp(() async { + adapter = await setUpBackendForTest(); + }); + + testWidgets('renders device rows after loading', (tester) async { + adapter.onGet( + '/devices', + (server) => server.reply(200, [ + deviceJson(macAddress: '00:00:00:00:00:01', owner: 'alice'), + deviceJson(macAddress: '00:00:00:00:00:02', owner: 'bob'), + ]), + ); + + await pumpScreen(tester, const DeviceList()); + await pumpUntilFound(tester, find.byType(DeviceRowWide)); + + expect(find.byType(DeviceRowWide), findsNWidgets(2)); + + await tearDownTree(tester); + }); + + testWidgets('shows the empty message when there are no devices', + (tester) async { + adapter.onGet('/devices', (server) => server.reply(200, [])); + + await pumpScreen(tester, const DeviceList()); + await pumpUntilFound(tester, find.text('No unregistered devices')); + + expect(find.text('No unregistered devices'), findsOneWidget); + expect(find.byType(DeviceRowWide), findsNothing); + + await tearDownTree(tester); + }); + + testWidgets('shows an error message when the request fails', (tester) async { + adapter.onGet('/devices', (server) => server.reply(500, {'error': 'boom'})); + + await pumpScreen(tester, const DeviceList()); + await pumpUntilFound( + tester, + find.textContaining('Backend error (status 500)'), + ); + + expect(find.textContaining('Backend error (status 500)'), findsOneWidget); + + await tearDownTree(tester); + }); +} diff --git a/frontend/test/widget/device_summary_card_test.dart b/frontend/test/widget/device_summary_card_test.dart new file mode 100644 index 0000000..1ec9a05 --- /dev/null +++ b/frontend/test/widget/device_summary_card_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:frontend/widgets/device_summary_card.dart'; +import 'package:http_mock_adapter/http_mock_adapter.dart'; + +import '../helpers/backend_test_harness.dart'; +import '../helpers/fixtures.dart'; +import '../helpers/pump_app.dart'; + +void main() { + late DioAdapter adapter; + + setUp(() async { + adapter = await setUpBackendForTest(); + }); + + testWidgets('shows a loading spinner before the summary arrives', + (tester) async { + adapter.onGet( + '/devices/summary', + (server) => server.reply(200, deviceSummaryJson()), + ); + + await pumpScreen(tester, const DeviceSummaryCard()); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + // Let the in-flight request resolve so no Dio timer is left pending. + await pumpUntilFound(tester, find.byType(Divider)); + await tearDownTree(tester); + }); + + testWidgets('renders the summary numbers after loading', (tester) async { + adapter.onGet( + '/devices/summary', + (server) => server.reply( + 200, + deviceSummaryJson(totalRegistered: 12, seenLastDayRegistered: 3), + ), + ); + + await pumpScreen(tester, const DeviceSummaryCard()); + await pumpUntilFound(tester, find.text('12')); + + expect(find.text('12'), findsOneWidget); + expect(find.text('3'), findsWidgets); + + await tearDownTree(tester); + }); + + testWidgets('shows the error message when the request fails', (tester) async { + adapter.onGet( + '/devices/summary', + (server) => server.reply(500, {'error': 'boom'}), + ); + + await pumpScreen(tester, const DeviceSummaryCard()); + await pumpUntilFound( + tester, + find.textContaining('Backend error (status 500)'), + ); + + expect( + find.textContaining('Backend error (status 500)'), + findsOneWidget, + ); + + await tearDownTree(tester); + }); +} diff --git a/frontend/test/widget/home_screen_test.dart b/frontend/test/widget/home_screen_test.dart new file mode 100644 index 0000000..3f54616 --- /dev/null +++ b/frontend/test/widget/home_screen_test.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:frontend/home/home_screen.dart'; +import 'package:http_mock_adapter/http_mock_adapter.dart'; + +import '../helpers/backend_test_harness.dart'; +import '../helpers/fixtures.dart'; +import '../helpers/pump_app.dart'; + +void main() { + late DioAdapter adapter; + + setUp(() async { + adapter = await setUpBackendForTest(); + }); + + void stubHomeEndpoints() { + adapter.onGet( + '/notifications', + (server) => server.reply(200, [ + notificationJson(id: 1, title: 'New device found'), + ]), + ); + adapter.onGet( + '/devices/summary', + (server) => server.reply(200, deviceSummaryJson(totalRegistered: 5)), + ); + adapter.onGet( + '/arp_scanner/status', + (server) => server.reply(200, intervalScannerJson(isRunning: true)), + ); + adapter.onGet( + '/mdns_scanner/status', + (server) => server.reply(200, listenerScannerJson(isListening: true)), + ); + adapter.onGet( + '/ssdp_scanner/status', + (server) => server.reply(200, listenerScannerJson()), + ); + adapter.onGet( + '/dhcp_scanner/status', + (server) => server.reply(200, listenerScannerJson()), + ); + adapter.onGet( + '/snmp_scanner/status', + (server) => server.reply(200, intervalScannerJson()), + ); + } + + testWidgets('shows notifications, the device summary and scanner statuses', + (tester) async { + stubHomeEndpoints(); + + // Below the 700px breakpoint the home screen uses its single-column layout, + // giving the cards full width and avoiding the cramped fixed-width side + // column (whose rows overflow under the test font metrics). + await pumpScreen(tester, const HomeScreen(), size: const Size(690, 2000)); + await pumpUntilFound(tester, find.textContaining('New device found')); + + expect(find.textContaining('New device found'), findsOneWidget); + expect(find.text('Registered in the system'), findsOneWidget); + expect(find.text('5'), findsOneWidget); + + await tearDownTree(tester); + }); +} diff --git a/frontend/test/widget/scanner_status_card_test.dart b/frontend/test/widget/scanner_status_card_test.dart new file mode 100644 index 0000000..a75a13a --- /dev/null +++ b/frontend/test/widget/scanner_status_card_test.dart @@ -0,0 +1,57 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:frontend/widgets/scanner_status_card.dart'; + +import '../helpers/backend_test_harness.dart'; +import '../helpers/pump_app.dart'; + +ScannerStatus _resolve(BuildContext context, int value, double elapsed) => + (color: Colors.green, label: 'Value $value', sublabels: ['detail line']); + +void main() { + setUp(() async { + await setUpBackendForTest(); + }); + + testWidgets('shows a spinner, then the resolved label and sublabels', + (tester) async { + await pumpScreen( + tester, + ScannerStatusCard( + title: 'Probe', + fetch: ({cancelToken}) async => 42, + resolver: _resolve, + ), + ); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + await pumpUntilFound(tester, find.text('Value 42')); + + expect(find.text('Probe'), findsOneWidget); + expect(find.text('detail line'), findsOneWidget); + + await tearDownTree(tester); + }); + + testWidgets('renders an Error state when the fetch fails', (tester) async { + await pumpScreen( + tester, + ScannerStatusCard( + title: 'Probe', + fetch: ({cancelToken}) async => throw DioException( + requestOptions: RequestOptions(path: '/x'), + type: DioExceptionType.connectionError, + ), + resolver: _resolve, + ), + ); + + await pumpUntilFound(tester, find.text('Error')); + + expect(find.text('Error'), findsOneWidget); + + await tearDownTree(tester); + }); +} diff --git a/frontend/test/widget/settings_test.dart b/frontend/test/widget/settings_test.dart new file mode 100644 index 0000000..b72489f --- /dev/null +++ b/frontend/test/widget/settings_test.dart @@ -0,0 +1,71 @@ +import 'package:encrypter/encrypter/xor.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:frontend/settings/settings.dart'; + +import '../helpers/backend_test_harness.dart'; +import '../helpers/pump_app.dart'; + +void main() { + setUp(() async { + await setUpBackendForTest( + prefs: { + 'base_url': 'http://my.server/api', + 'api_key': XOR().xorEncode('topsecret'), + 'theme': 'catppuccin_mocha', + }, + ); + }); + + testWidgets('prefills the form from stored preferences', (tester) async { + await pumpScreen(tester, const Settings()); + + expect(find.text('http://my.server/api'), findsOneWidget); + expect(find.text('Catppuccin Mocha'), findsOneWidget); + }); + + testWidgets('validates an empty base URL when testing the connection', + (tester) async { + await pumpScreen(tester, const Settings()); + + await tester.enterText(find.byType(TextFormField).first, ''); + await tester.tap(find.widgetWithText(ElevatedButton, 'Test')); + await tester.pump(); + + expect(find.text('The URL cannot be empty'), findsOneWidget); + }); + + testWidgets('disables Save once the connection details are edited', + (tester) async { + await pumpScreen(tester, const Settings()); + final saveButton = find.widgetWithText(ElevatedButton, 'Save'); + + expect( + tester.widget(saveButton).onPressed, + isNotNull, + reason: 'enabled for the unmodified, prefilled form', + ); + + await tester.enterText( + find.byType(TextFormField).first, + 'http://changed/api', + ); + await tester.pump(); + + expect( + tester.widget(saveButton).onPressed, + isNull, + reason: 'disabled until the new connection is tested', + ); + }); + + testWidgets('saving the unmodified form shows a success message', + (tester) async { + await pumpScreen(tester, const Settings()); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Save')); + await pumpUntilFound(tester, find.text('Settings saved successfully')); + + expect(find.text('Settings saved successfully'), findsOneWidget); + }); +} diff --git a/frontend/test/widget/status_screen_test.dart b/frontend/test/widget/status_screen_test.dart new file mode 100644 index 0000000..a8850e0 --- /dev/null +++ b/frontend/test/widget/status_screen_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:frontend/status/status_screen.dart'; +import 'package:http_mock_adapter/http_mock_adapter.dart'; + +import '../helpers/backend_test_harness.dart'; +import '../helpers/fixtures.dart'; +import '../helpers/pump_app.dart'; + +void main() { + late DioAdapter adapter; + + setUp(() async { + adapter = await setUpBackendForTest(); + }); + + void stubAllScanners() { + adapter.onGet( + '/arp_scanner/status', + (server) => server.reply( + 200, + intervalScannerJson(isRunning: true, runningForSeconds: 10), + ), + ); + adapter.onGet( + '/mdns_scanner/status', + (server) => server.reply( + 200, + listenerScannerJson(isListening: true, devicesSeen: 2), + ), + ); + adapter.onGet( + '/ssdp_scanner/status', + (server) => server.reply(200, listenerScannerJson()), + ); + adapter.onGet( + '/dhcp_scanner/status', + (server) => server.reply(200, listenerScannerJson()), + ); + adapter.onGet( + '/snmp_scanner/status', + (server) => server.reply(200, intervalScannerJson()), + ); + } + + testWidgets('renders each scanner card with its resolved status', + (tester) async { + stubAllScanners(); + + await pumpScreen(tester, const StatusScreen()); + await pumpUntilFound(tester, find.text('Running')); + + expect(find.text('Status'), findsOneWidget); + expect(find.text('ARP Scanner'), findsOneWidget); + expect(find.text('mDNS Scanner'), findsOneWidget); + expect(find.text('Running'), findsWidgets); + expect(find.text('Listening'), findsWidgets); + expect(find.byType(CircularProgressIndicator), findsNothing); + + await tearDownTree(tester); + }); +}