315 lines
9.6 KiB
Dart
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,
|
|
);
|
|
}
|
|
}
|