cyberhybridhub/test/admin/risk/admin_portal_risk_test.dart
2026-05-31 11:17:12 -05:00

315 lines
9.6 KiB
Dart

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 <String>[
'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(<String, dynamic>{
'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<SyncRunLogRepositoryState> first = repository.loadMore();
final SyncRunLogRepositoryState skipped = await repository.loadMore();
expect(api.inFlightLoadMoreCalls, 1);
expect(skipped.history.map((SyncRunEvent e) => e.id), <int>[10]);
api.releaseLoadMore();
final SyncRunLogRepositoryState loaded = await first;
expect(loaded.history.map((SyncRunEvent e) => e.id), <int>[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), <int>[10, 9]);
final SyncRunLogRepositoryState refreshed = await repository.refresh();
expect(refreshed.history.map((SyncRunEvent e) => e.id), <int>[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(<String, dynamic>{
'runs': <dynamic>[],
'pinned': <dynamic>[],
}),
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<void> _gate = Completer<void>();
int inFlightLoadMoreCalls = 0;
int completedLoadMoreCalls = 0;
void releaseLoadMore() {
if (!_gate.isCompleted) {
_gate.complete();
}
}
@override
Future<SyncRunLogPage> fetchSyncRuns({
int limit = 50,
DateTime? before,
String? kind,
}) async {
if (before == null) {
return SyncRunLogPage(
runs: <SyncRunEvent>[_event(id: 10, startedAt: DateTime.utc(2026, 5, 27, 10))],
pinned: const <SyncRunEvent>[],
nextBefore: DateTime.utc(2026, 5, 26),
);
}
inFlightLoadMoreCalls++;
await _gate.future;
completedLoadMoreCalls++;
return SyncRunLogPage(
runs: <SyncRunEvent>[_event(id: 9, startedAt: DateTime.utc(2026, 5, 26, 10))],
pinned: const <SyncRunEvent>[],
nextBefore: null,
);
}
}
class _FakePagedApi extends MarketHistoryAdminApi {
_FakePagedApi(this._t0) : super(tokenProvider: () async => 'token');
final DateTime _t0;
int _cursor = 0;
@override
Future<SyncRunLogPage> fetchSyncRuns({
int limit = 50,
DateTime? before,
String? kind,
}) async {
if (_cursor == 0) {
_cursor++;
return SyncRunLogPage(
runs: <SyncRunEvent>[_event(id: 10, startedAt: _t0)],
pinned: const <SyncRunEvent>[],
nextBefore: DateTime.utc(2026, 5, 26),
);
}
if (_cursor == 1 && before != null) {
_cursor++;
return SyncRunLogPage(
runs: <SyncRunEvent>[
_event(id: 9, startedAt: _t0.subtract(const Duration(hours: 1))),
],
pinned: const <SyncRunEvent>[],
nextBefore: null,
);
}
return SyncRunLogPage(
runs: <SyncRunEvent>[_event(id: 20, startedAt: _t0.add(const Duration(hours: 2)))],
pinned: const <SyncRunEvent>[],
nextBefore: null,
);
}
}
class _EmptyApi extends MarketHistoryAdminApi {
_EmptyApi() : super(tokenProvider: () async => 'token');
@override
Future<SyncRunLogPage> fetchSyncRuns({
int limit = 50,
DateTime? before,
String? kind,
}) async {
return const SyncRunLogPage(
runs: <SyncRunEvent>[],
pinned: <SyncRunEvent>[],
nextBefore: null,
);
}
}