import 'dart:async'; import 'dart:convert'; import 'package:cyberhybridhub/admin/models/sync_run_event.dart'; import 'package:cyberhybridhub/admin/repositories/sync_run_log_repository.dart'; import 'package:cyberhybridhub/admin/services/market_history_admin_api.dart'; import 'package:cyberhybridhub/admin/utils/sync_run_formatters.dart'; import 'package:cyberhybridhub/admin/widgets/sync_run_expansion_tile.dart'; import 'package:cyberhybridhub/theme/app_theme.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; /// FLUTTER-TDD-PLAN.md Section 8 — risk-driven checklist (Flutter layer). void main() { group('rate-limit text variants', () { for (final String message in [ 'HTTP 429 Too Many Requests', 'Rate Limit exceeded', 'RATE LIMITED by upstream', '429', ]) { test('SyncRunEvent infers rate_limit from "$message"', () { final SyncRunEvent event = SyncRunEvent.fromJson({ 'id': 1, 'kind': 'backfill', 'startedAt': '2026-05-27T01:00:00Z', 'finishedAt': '2026-05-27T01:00:05Z', 'rowsWritten': 0, 'rowsRemoved': 0, 'error': message, }); expect(event.severity, SyncRunSeverity.rateLimit); if (message.contains('429')) { expect(parseHttpStatus(message), '429'); } }); } }); group('timezone display', () { test('formatLocalTimestamp matches DateTime.toLocal()', () { final DateTime utc = DateTime.utc(2026, 5, 27, 14, 30); final DateTime local = utc.toLocal(); final String formatted = formatLocalTimestamp(utc); expect( formatted, '${local.year}-${local.month.toString().padLeft(2, '0')}-' '${local.day.toString().padLeft(2, '0')} ' '${local.hour.toString().padLeft(2, '0')}:' '${local.minute.toString().padLeft(2, '0')}', ); expect(formatted, isNot(contains('Z'))); }); testWidgets('expansion tile shows Started in local timezone', ( WidgetTester tester, ) async { final DateTime utc = DateTime.utc(2026, 5, 27, 14, 30); final SyncRunEvent event = SyncRunEvent( id: 42, kind: 'cleanup', startedAt: utc, finishedAt: utc.add(const Duration(minutes: 1)), rowsWritten: 0, rowsRemoved: 10, error: null, severity: SyncRunSeverity.ok, status: SyncRunStatus.success, durationMs: 60000, summary: '10 rows removed', ); await tester.pumpWidget( MaterialApp( theme: buildAppTheme(), home: Scaffold(body: SyncRunExpansionTile(event: event)), ), ); await tester.tap(find.byType(ExpansionTile)); await tester.pumpAndSettle(); expect(find.text(formatLocalTimestamp(utc)), findsOneWidget); }); }); group('pagination refresh + load-more race', () { test('loadMore ignores overlapping calls while first page fetch is in flight', () async { final _DelayedLoadMoreApi api = _DelayedLoadMoreApi(); final SyncRunLogRepository repository = SyncRunLogRepository(api: api); await repository.loadInitial(); final Future first = repository.loadMore(); final SyncRunLogRepositoryState skipped = await repository.loadMore(); expect(api.inFlightLoadMoreCalls, 1); expect(skipped.history.map((SyncRunEvent e) => e.id), [10]); api.releaseLoadMore(); final SyncRunLogRepositoryState loaded = await first; expect(loaded.history.map((SyncRunEvent e) => e.id), [10, 9]); expect(api.completedLoadMoreCalls, 1); }); test('refresh after loadMore replaces history without holes or duplicates', () async { final DateTime t0 = DateTime.utc(2026, 5, 27, 10); final SyncRunLogRepository repository = SyncRunLogRepository( api: _FakePagedApi(t0), ); await repository.loadInitial(); await repository.loadMore(); final SyncRunLogRepositoryState beforeRefresh = repository.state; expect(beforeRefresh.history.map((SyncRunEvent e) => e.id), [10, 9]); final SyncRunLogRepositoryState refreshed = await repository.refresh(); expect(refreshed.history.map((SyncRunEvent e) => e.id), [20]); expect(refreshed.history.map((SyncRunEvent e) => e.id).toSet(), hasLength(1)); }); }); group('empty payload safety', () { test('fetchSyncRuns accepts empty runs and pinned arrays', () async { final MarketHistoryAdminApi api = MarketHistoryAdminApi( tokenProvider: () async => 'token', client: MockClient( (http.Request request) async => http.Response( jsonEncode({ 'runs': [], 'pinned': [], }), 200, ), ), ); final SyncRunLogPage page = await api.fetchSyncRuns(); expect(page.runs, isEmpty); expect(page.pinned, isEmpty); expect(page.nextBefore, isNull); }); test('fetchSyncRuns accepts minimal empty object', () async { final MarketHistoryAdminApi api = MarketHistoryAdminApi( tokenProvider: () async => 'token', client: MockClient( (http.Request request) async => http.Response('{}', 200), ), ); final SyncRunLogPage page = await api.fetchSyncRuns(); expect(page.runs, isEmpty); expect(page.pinned, isEmpty); expect(page.nextBefore, isNull); }); test('loadInitial with empty API response does not crash repository', () async { final SyncRunLogRepository repository = SyncRunLogRepository( api: _EmptyApi(), ); final SyncRunLogRepositoryState state = await repository.loadInitial(); expect(state.pinned, isEmpty); expect(state.history, isEmpty); expect(state.errorMessage, isNull); }); }); testWidgets('very long error text renders when expansion tile is opened', ( WidgetTester tester, ) async { tester.view.physicalSize = const Size(800, 1600); tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.reset); final String longError = 'X' * 600; final SyncRunEvent event = SyncRunEvent( id: 99, kind: 'backfill', startedAt: DateTime.utc(2026, 5, 27, 10), finishedAt: DateTime.utc(2026, 5, 27, 10, 5), rowsWritten: 0, rowsRemoved: 0, error: longError, severity: SyncRunSeverity.error, status: SyncRunStatus.failed, durationMs: 300000, summary: 'Run failed', ); await tester.pumpWidget( MaterialApp( theme: buildAppTheme(), home: Scaffold(body: SyncRunExpansionTile(event: event)), ), ); await tester.tap(find.byType(ExpansionTile)); await tester.pumpAndSettle(); expect(find.byKey(const Key('sync-run-error-99')), findsOneWidget); expect(find.text(longError), findsWidgets); }); } SyncRunEvent _event({ required int id, required DateTime startedAt, }) { return SyncRunEvent( id: id, kind: 'cleanup', startedAt: startedAt, finishedAt: startedAt.add(const Duration(minutes: 1)), rowsWritten: 0, rowsRemoved: 1, error: null, severity: SyncRunSeverity.ok, status: SyncRunStatus.success, durationMs: 60000, summary: 'summary', ); } class _DelayedLoadMoreApi extends MarketHistoryAdminApi { _DelayedLoadMoreApi() : super(tokenProvider: () async => 'token'); final Completer _gate = Completer(); int inFlightLoadMoreCalls = 0; int completedLoadMoreCalls = 0; void releaseLoadMore() { if (!_gate.isCompleted) { _gate.complete(); } } @override Future fetchSyncRuns({ int limit = 50, DateTime? before, String? kind, }) async { if (before == null) { return SyncRunLogPage( runs: [_event(id: 10, startedAt: DateTime.utc(2026, 5, 27, 10))], pinned: const [], nextBefore: DateTime.utc(2026, 5, 26), ); } inFlightLoadMoreCalls++; await _gate.future; completedLoadMoreCalls++; return SyncRunLogPage( runs: [_event(id: 9, startedAt: DateTime.utc(2026, 5, 26, 10))], pinned: const [], nextBefore: null, ); } } class _FakePagedApi extends MarketHistoryAdminApi { _FakePagedApi(this._t0) : super(tokenProvider: () async => 'token'); final DateTime _t0; int _cursor = 0; @override Future fetchSyncRuns({ int limit = 50, DateTime? before, String? kind, }) async { if (_cursor == 0) { _cursor++; return SyncRunLogPage( runs: [_event(id: 10, startedAt: _t0)], pinned: const [], nextBefore: DateTime.utc(2026, 5, 26), ); } if (_cursor == 1 && before != null) { _cursor++; return SyncRunLogPage( runs: [ _event(id: 9, startedAt: _t0.subtract(const Duration(hours: 1))), ], pinned: const [], nextBefore: null, ); } return SyncRunLogPage( runs: [_event(id: 20, startedAt: _t0.add(const Duration(hours: 2)))], pinned: const [], nextBefore: null, ); } } class _EmptyApi extends MarketHistoryAdminApi { _EmptyApi() : super(tokenProvider: () async => 'token'); @override Future fetchSyncRuns({ int limit = 50, DateTime? before, String? kind, }) async { return const SyncRunLogPage( runs: [], pinned: [], nextBefore: null, ); } }