3cb980b629
Security — enforce write authorization server-side (was UI/RPC-only): - it_service_requests RLS: block cross-office read/edit + self-approve (QA-015) - pass_slips RLS: owner can complete but not self-approve (QA-046) - swap_requests RLS: scope select/update to participants + admin (QA-047) - storage: tighten it_service_attachments + task_attachments write/delete (QA-027) - admin_user_management edge function: allow programmers to manage users (QA-016) Fixes: - workforce generator "uncovered shifts" false alarms (QA-043/044) - network-map VLAN + New-location dialog validation, disabled-until-valid (QA-048) - de-flake time-of-day-dependent dashboard metrics test (QA-045) Toolchain: - upgrade to Flutter 3.47.5 / Dart 3.13.4; font_awesome_flutter 11.0.0, flutter_quill 11.6.0, pdfrx 2.6.5; clear resulting deprecations (QA-002) analyze clean; 139 tests pass; web build succeeds. Report + evidence in docs/qa/. Note: also carries the in-progress Brick model cleanup already present in the working tree. QA-001 (AI keys public in the build) is deferred by owner decision. Co-Authored-By: claude-flow <ruv@ruv.net>
233 lines
6.8 KiB
Dart
233 lines
6.8 KiB
Dart
import 'dart:typed_data';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:tasq/models/office.model.dart';
|
|
import 'package:tasq/models/profile.model.dart';
|
|
import 'package:tasq/models/user_office.dart';
|
|
import 'package:tasq/providers/profile_provider.dart';
|
|
import 'package:tasq/providers/tickets_provider.dart';
|
|
import 'package:tasq/providers/user_offices_provider.dart';
|
|
import 'package:tasq/providers/auth_provider.dart';
|
|
import 'package:tasq/screens/profile/profile_screen.dart';
|
|
import 'package:tasq/widgets/multi_select_picker.dart';
|
|
import 'package:awesome_snackbar_content/awesome_snackbar_content.dart';
|
|
|
|
class _FakeProfileController implements ProfileController {
|
|
String? lastFullName;
|
|
String? lastPassword;
|
|
|
|
@override
|
|
Future<void> updateFullName({
|
|
required String userId,
|
|
required String fullName,
|
|
}) async {
|
|
lastFullName = fullName;
|
|
}
|
|
|
|
@override
|
|
Future<void> updatePassword(String password) async {
|
|
lastPassword = password;
|
|
}
|
|
|
|
@override
|
|
Future<String> uploadAvatar({
|
|
required String userId,
|
|
required Uint8List bytes,
|
|
required String fileName,
|
|
}) async {
|
|
return 'https://example.com/avatar.jpg';
|
|
}
|
|
|
|
@override
|
|
Future<String> uploadFacePhoto({
|
|
required String userId,
|
|
required Uint8List bytes,
|
|
required String fileName,
|
|
}) async {
|
|
return 'https://example.com/face.jpg';
|
|
}
|
|
|
|
@override
|
|
Future<Uint8List?> downloadFacePhoto(String userId, {String? enrolledAt}) async {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
class _FakeUserOfficesController implements UserOfficesController {
|
|
final List<Map<String, String>> assigned = [];
|
|
|
|
@override
|
|
Future<void> assignUserOffice({
|
|
required String userId,
|
|
required String officeId,
|
|
}) async {
|
|
assigned.add({'userId': userId, 'officeId': officeId});
|
|
}
|
|
|
|
@override
|
|
Future<void> removeUserOffice({
|
|
required String userId,
|
|
required String officeId,
|
|
}) async {
|
|
assigned.removeWhere(
|
|
(e) => e['userId'] == userId && e['officeId'] == officeId,
|
|
);
|
|
}
|
|
}
|
|
|
|
void main() {
|
|
final officeA = Office(id: 'office-1', name: 'HQ');
|
|
final officeB = Office(id: 'office-2', name: 'Branch');
|
|
final profile = Profile(id: 'user-1', role: 'standard', fullName: 'Paola');
|
|
|
|
ProviderScope buildApp({required List<Override> overrides}) {
|
|
return ProviderScope(
|
|
overrides: [
|
|
currentUserIdProvider.overrideWithValue(profile.id),
|
|
sessionProvider.overrideWithValue(null),
|
|
...overrides,
|
|
],
|
|
child: const MaterialApp(home: Scaffold(body: ProfileScreen())),
|
|
);
|
|
}
|
|
|
|
testWidgets('renders fields and sections', (tester) async {
|
|
await tester.pumpWidget(
|
|
buildApp(
|
|
overrides: [
|
|
currentProfileProvider.overrideWith((ref) => Stream.value(profile)),
|
|
officesProvider.overrideWith(
|
|
(ref) => Stream.value([officeA, officeB]),
|
|
),
|
|
userOfficesProvider.overrideWith(
|
|
(ref) => Stream.value(const <UserOffice>[]),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('My Profile'), findsOneWidget);
|
|
expect(find.text('Account details'), findsOneWidget);
|
|
expect(find.widgetWithText(TextFormField, 'Full name'), findsOneWidget);
|
|
expect(
|
|
find.byWidgetPredicate(
|
|
(w) => w is MultiSelectPicker<Office> && w.label == 'Offices',
|
|
),
|
|
findsOneWidget,
|
|
);
|
|
});
|
|
|
|
testWidgets('save details calls profile controller', (tester) async {
|
|
final fake = _FakeProfileController();
|
|
|
|
await tester.pumpWidget(
|
|
buildApp(
|
|
overrides: [
|
|
currentProfileProvider.overrideWith((ref) => Stream.value(profile)),
|
|
officesProvider.overrideWith((ref) => Stream.value([officeA])),
|
|
userOfficesProvider.overrideWith(
|
|
(ref) => Stream.value(const <UserOffice>[]),
|
|
),
|
|
profileControllerProvider.overrideWithValue(fake),
|
|
],
|
|
),
|
|
);
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.enterText(
|
|
find.widgetWithText(TextFormField, 'Full name'),
|
|
'New Name',
|
|
);
|
|
await tester.ensureVisible(find.text('Save details'));
|
|
await tester.tap(find.text('Save details'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(fake.lastFullName, equals('New Name'));
|
|
|
|
// should show a success snackbar using the awesome_snackbar_content package
|
|
// (AwesomeSnackbarContent uses its own SVG icons, not Material Icons)
|
|
expect(find.byType(AwesomeSnackbarContent), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('save offices assigns selected office', (tester) async {
|
|
final fakeOffices = _FakeUserOfficesController();
|
|
|
|
await tester.pumpWidget(
|
|
buildApp(
|
|
overrides: [
|
|
currentProfileProvider.overrideWith((ref) => Stream.value(profile)),
|
|
officesProvider.overrideWith(
|
|
(ref) => Stream.value([officeA, officeB]),
|
|
),
|
|
userOfficesProvider.overrideWith(
|
|
(ref) => Stream.value(const <UserOffice>[]),
|
|
),
|
|
userOfficesControllerProvider.overrideWithValue(fakeOffices),
|
|
],
|
|
),
|
|
);
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
// Open MultiSelect picker
|
|
final selectChip = find.byWidgetPredicate(
|
|
(w) => w is ActionChip && (w.label as Text).data == 'Select',
|
|
);
|
|
expect(selectChip, findsOneWidget);
|
|
await tester.ensureVisible(selectChip);
|
|
await tester.tap(selectChip);
|
|
await tester.pumpAndSettle();
|
|
|
|
// In the dialog, tap the first office checkbox and press Done
|
|
await tester.tap(find.text('HQ'));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('Done'));
|
|
await tester.pumpAndSettle();
|
|
|
|
// Save offices
|
|
await tester.tap(find.text('Save offices'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(fakeOffices.assigned, isNotEmpty);
|
|
expect(fakeOffices.assigned.first['officeId'], equals('office-1'));
|
|
});
|
|
|
|
testWidgets('change password calls controller', (tester) async {
|
|
final fake = _FakeProfileController();
|
|
|
|
await tester.pumpWidget(
|
|
buildApp(
|
|
overrides: [
|
|
currentProfileProvider.overrideWith((ref) => Stream.value(profile)),
|
|
officesProvider.overrideWith((ref) => Stream.value([officeA])),
|
|
userOfficesProvider.overrideWith(
|
|
(ref) => Stream.value(const <UserOffice>[]),
|
|
),
|
|
profileControllerProvider.overrideWithValue(fake),
|
|
],
|
|
),
|
|
);
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.enterText(
|
|
find.widgetWithText(TextFormField, 'New password'),
|
|
'new-pass-123',
|
|
);
|
|
await tester.enterText(
|
|
find.widgetWithText(TextFormField, 'Confirm password'),
|
|
'new-pass-123',
|
|
);
|
|
await tester.ensureVisible(find.text('Change password'));
|
|
await tester.tap(find.text('Change password'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(fake.lastPassword, equals('new-pass-123'));
|
|
});
|
|
}
|