import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http_mock_adapter/http_mock_adapter.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:jiu_client/core/api/api_client.dart'; import 'package:jiu_client/core/auth/auth_state.dart'; import 'package:jiu_client/core/config/license_copy.dart'; import 'package:jiu_client/core/models/page_result.dart'; import 'package:jiu_client/core/theme/themes.dart'; import 'package:jiu_client/models/license.dart'; import 'package:jiu_client/models/partner.dart'; import 'package:jiu_client/providers/license_provider.dart'; import 'package:jiu_client/providers/partner_provider.dart'; import 'package:jiu_client/screens/partners/partners_screen.dart'; // 只读角色:写按钮(新建/编辑/删除)应被 WriteGuard 隐藏。 // 替换原先因 ProductsScreen 重构而 skip 的旧用例,改测稳定的 PartnersScreen。 class _FakePartnerNotifier extends PartnerListNotifier { final AsyncValue> _fixed; _FakePartnerNotifier(String type, this._fixed) : super(type: type); @override Future> build() async { state = _fixed; return state.value ?? const PageResult(data: [], total: 0, page: 1, pageSize: 20); } @override void reload() {} @override void setKeyword(String keyword) {} @override void setPage(int page) {} @override Future createPartner(Map data) async {} @override Future updatePartner(int id, Map data) async {} @override Future deletePartner(int id) async {} } class _FakeLicenseNotifier extends LicenseNotifier { @override Future build() async => const LicenseInfo( id: 1, type: 'annual', isActive: true, maxDevices: 3, phase: 'normal', ); } const _suppliers = PageResult( data: [Partner(id: 1, code: 'S001', name: '茅台供应商', type: 'supplier')], total: 1, page: 1, pageSize: 20, ); Widget _buildApp({required bool readonly}) { return ProviderScope( overrides: [ isReadonlyProvider.overrideWithValue(readonly), licenseProvider.overrideWith(() => _FakeLicenseNotifier()), supplierListProvider.overrideWith( () => _FakePartnerNotifier('supplier', const AsyncValue.data(_suppliers)), ), customerListProvider.overrideWith( () => _FakePartnerNotifier( 'customer', const AsyncValue.data( PageResult(data: [], total: 0, page: 1, pageSize: 20)), ), ), ], child: MaterialApp(theme: buildTheme('a'), home: const Scaffold(body: PartnersScreen())), ); } void main() { TestWidgetsFlutterBinding.ensureInitialized(); // 仅隐藏按钮不够:只读账号若绕过 UI(或后端授权过期)直接提交写操作, // 必须验证 403 真的被 apiClientProvider 的 onForbidden 捕获并浮出只读提示。 test('只读账号提交写操作:后端 403(READONLY_USER) 经 apiClientProvider 浮出只读提示', () async { SharedPreferences.setMockInitialValues({}); final container = ProviderContainer(); addTearDown(container.dispose); // 登录一个只读账号,使 apiClientProvider 携带 token 并构建真实客户端。 await container.read(authStateProvider.notifier).login(const AuthUser( accessToken: 'ro-token', refreshToken: 'ro-refresh', username: 'viewer', realName: '只读用户', shopNo: 'S001', shopId: 1, role: 'readonly', )); final client = container.read(apiClientProvider); final adapter = DioAdapter( dio: client.dioForTest, matcher: const FullHttpRequestMatcher(), ); adapter.onPost( '/partners', (server) => server.reply(403, {'error': 'readonly user', 'code': 'READONLY_USER'}), data: {'name': '新供应商'}, ); // 写请求被后端拒绝。 await expectLater( client.post('/partners', data: {'name': '新供应商'}), throwsA(isA()), ); // provider 的 onForbidden 把只读提示浮到全局消息通道(用户可见的「403 浮出」)。 expect(container.read(apiMessageProvider), LicenseCopy.readonlyUserToast); }); testWidgets('只读角色:新建/编辑/删除按钮全部隐藏', (tester) async { tester.view.physicalSize = const Size(1280, 800); tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.resetPhysicalSize); await tester.pumpWidget(_buildApp(readonly: true)); await tester.pumpAndSettle(); // 供应商已渲染,证明列表确实加载。 expect(find.text('茅台供应商'), findsOneWidget); // 写按钮被 WriteGuard 隐藏。 expect(find.text('新建'), findsNothing); expect(find.byKey(const Key('btn_edit_1')), findsNothing); expect(find.byKey(const Key('btn_delete_1')), findsNothing); }); testWidgets('普通角色(normal 授权):写按钮可见', (tester) async { tester.view.physicalSize = const Size(1280, 800); tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.resetPhysicalSize); await tester.pumpWidget(_buildApp(readonly: false)); await tester.pumpAndSettle(); expect(find.text('茅台供应商'), findsOneWidget); expect(find.text('新建'), findsWidgets); expect(find.byKey(const Key('btn_edit_1')), findsOneWidget); expect(find.byKey(const Key('btn_delete_1')), findsOneWidget); }); }