bottleneckcrm/mobile/test/unit/analytics_provider_sections_test.dart
Ashwin dd6ca2958d Add unit tests for organization admin checks and saved views provider
- Implement tests for `isOrgAdminProvider` to validate admin roles based on organization membership and role.
- Create tests for `saved_views_provider` to ensure correct API interactions for saving, renaming, and deleting saved views.
- Enhance ticket tree tests to accurately reflect inactive and merged ticket handling.
- Update CSV import tests to verify error handling and file saving functionality.
- Introduce tests for deal cards to ensure correct display of next steps and task statuses.
2026-09-26 20:38:32 +05:30

203 lines
6.7 KiB
Dart

import 'dart:convert';
import 'package:bottle_crm/providers/analytics_provider.dart';
import 'package:bottle_crm/screens/tickets/ticket_analytics_screen.dart';
import 'package:bottle_crm/services/api_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
/// One failing analytics call used to blank the whole dashboard: the provider
/// kept a single `error`, and the screen showed only that. Each section now
/// loads, fails and retries on its own.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('AnalyticsNotifier', () {
late _AnalyticsClient client;
late ProviderContainer container;
setUp(() {
client = _AnalyticsClient();
ApiService().setClientForTesting(client);
container = ProviderContainer();
});
tearDown(() {
container.dispose();
ApiService().setClientForTesting(http.Client());
});
Future<AnalyticsDashboard> load() async {
// Let the load `build()` schedules start first; the one below then
// supersedes it, which is also the path a filter change takes.
container.read(analyticsProvider);
await Future<void>.delayed(Duration.zero);
await container
.read(analyticsProvider.notifier)
.setQuery(const AnalyticsQuery());
return container.read(analyticsProvider);
}
test('one failing call is that section only', () async {
client.failing.add('/cases/analytics/frt/');
final data = await load();
expect(data.isLoading, isFalse);
expect(data.errors.keys, [AnalyticsSection.frt]);
expect(data.errors[AnalyticsSection.frt], 'Server exploded');
expect(data.frt, isNull);
// Everything else still arrived.
expect(data.nrt?['count'], 7);
expect(data.mttr?['count'], 7);
expect(data.backlog?['series'], isNotEmpty);
expect(data.sla?['frt_breach_rate'], 0.5);
expect(data.csat?['count'], 7);
expect(data.agents.single['email'], 'a@example.com');
});
test('retry fetches that section alone and clears its error', () async {
client.failing.add('/cases/csat/aggregate/');
await load();
expect(container.read(analyticsProvider).errors.keys, [
AnalyticsSection.csat,
]);
client.failing.clear();
client.paths.clear();
await container
.read(analyticsProvider.notifier)
.retry(AnalyticsSection.csat);
final data = container.read(analyticsProvider);
expect(client.paths, ['/cases/csat/aggregate/']);
expect(data.errors, isEmpty);
expect(data.csat?['count'], 7);
expect(data.frt?['count'], 7);
});
test('a failing agents call keeps no stale rows', () async {
client.failing.add('/cases/analytics/agents/');
final data = await load();
expect(data.agents, isEmpty);
expect(data.errors.keys, [AnalyticsSection.agents]);
});
test('everything answering is no error at all', () async {
final data = await load();
expect(data.errors, isEmpty);
});
});
group('TicketAnalyticsScreen at 390px', () {
void usePhone(WidgetTester tester, {double textScale = 1.0}) {
tester.view.devicePixelRatio = 3.0;
tester.view.physicalSize = const Size(390 * 3, 844 * 3);
tester.platformDispatcher.textScaleFactorTestValue = textScale;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
}
for (final scale in [1.0, 1.3]) {
testWidgets('a failed section says so and the rest render, at $scale', (
tester,
) async {
usePhone(tester, textScale: scale);
final fake = _FakeAnalytics(
const AnalyticsDashboard(
nrt: {
'median_hours': 2.5,
'p90_hours': 6.0,
'count': 14,
'breach_count': 3,
},
errors: {AnalyticsSection.frt: 'First response did not load.'},
),
);
await tester.pumpWidget(
ProviderScope(
overrides: [analyticsProvider.overrideWith(() => fake)],
child: const MaterialApp(home: TicketAnalyticsScreen()),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('First response did not load.'), findsOneWidget);
// The neighbouring tile still shows its figures.
expect(find.text('2.5h'), findsOneWidget);
expect(find.text('p90 6.0h · 14 answered'), findsOneWidget);
// The failed tile does not pretend to zero breaches.
expect(find.text('0 breached'), findsNothing);
final retry = find.text('Retry');
expect(retry, findsOneWidget);
expect(tester.getSize(find.byType(TextButton)).height, greaterThan(43));
await tester.tap(retry);
await tester.pumpAndSettle();
expect(fake.retried, [AnalyticsSection.frt]);
});
}
});
}
/// Every analytics endpoint answers 200 with a small body, except the paths in
/// [failing], which answer 500.
class _AnalyticsClient extends http.BaseClient {
final Set<String> failing = {};
final List<String> paths = [];
static const _bodies = <String, Map<String, dynamic>>{
'/cases/analytics/frt/': {'count': 7, 'median_hours': 1.0},
'/cases/analytics/nrt/': {'count': 7, 'median_hours': 1.0},
'/cases/analytics/mttr/': {'count': 7, 'median_hours': 1.0},
'/cases/analytics/backlog/': {
'series': [
{'open_count': 3, 'urgent_count': 1},
],
},
'/cases/analytics/sla/': {'frt_breach_rate': 0.5},
'/cases/csat/aggregate/': {'count': 7, 'average': 4.0},
'/cases/analytics/agents/': {
'results': [
{'email': 'a@example.com', 'handled': 2},
],
},
};
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
final path = request.url.path.replaceFirst(RegExp(r'^/api'), '');
paths.add(path);
final fail = failing.contains(path);
final body = fail
? const {'detail': 'Server exploded'}
: (_bodies[path] ?? const {});
return http.StreamedResponse(
Stream.value(utf8.encode(jsonEncode(body))),
fail ? 500 : 200,
request: request,
);
}
}
class _FakeAnalytics extends AnalyticsNotifier {
_FakeAnalytics(this._data);
final AnalyticsDashboard _data;
final List<AnalyticsSection> retried = [];
@override
AnalyticsDashboard build() => _data;
@override
Future<void> setQuery(AnalyticsQuery query) async {}
@override
Future<void> retry(AnalyticsSection section) async => retried.add(section);
}