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>
413 lines
14 KiB
Dart
413 lines
14 KiB
Dart
// ignore_for_file: deprecated_member_use
|
||
|
||
import 'dart:async';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:tasq/models/it_service_request.model.dart';
|
||
import 'package:tasq/models/it_service_request_assignment.model.dart';
|
||
import 'package:tasq/models/profile.model.dart';
|
||
import 'package:tasq/models/ticket.model.dart';
|
||
import 'package:tasq/models/task.model.dart';
|
||
import 'package:tasq/models/task_assignment.dart';
|
||
import 'package:tasq/models/ticket_message.dart';
|
||
import 'package:tasq/models/duty_schedule.model.dart';
|
||
import 'package:tasq/models/attendance_log.model.dart';
|
||
import 'package:tasq/models/live_position.dart';
|
||
import 'package:tasq/models/leave_of_absence.model.dart';
|
||
import 'package:tasq/models/pass_slip.model.dart';
|
||
import 'package:tasq/models/team.model.dart';
|
||
import 'package:tasq/models/team_member.dart';
|
||
import 'package:tasq/screens/dashboard/dashboard_screen.dart';
|
||
import 'package:tasq/utils/app_time.dart';
|
||
|
||
// underlying providers needed by the dashboard metrics logic
|
||
import 'package:tasq/providers/it_service_request_provider.dart';
|
||
import 'package:tasq/providers/profile_provider.dart';
|
||
import 'package:tasq/providers/tickets_provider.dart';
|
||
import 'package:tasq/providers/tasks_provider.dart';
|
||
import 'package:tasq/providers/workforce_provider.dart'; // dutySchedulesProvider
|
||
import 'package:tasq/providers/attendance_provider.dart';
|
||
import 'package:tasq/providers/whereabouts_provider.dart';
|
||
import 'package:tasq/providers/leave_provider.dart';
|
||
import 'package:tasq/providers/pass_slip_provider.dart';
|
||
import 'package:tasq/providers/teams_provider.dart';
|
||
|
||
void main() {
|
||
group('dashboardMetricsProvider', () {
|
||
test('service request in progress causes status to become On task', () async {
|
||
AppTime.initialize();
|
||
final now = AppTime.now();
|
||
// create a single IT staff profile
|
||
final profile = Profile(
|
||
id: 'u1',
|
||
fullName: 'Staff One',
|
||
role: 'it_staff',
|
||
avatarUrl: null,
|
||
allowTracking: false,
|
||
);
|
||
|
||
final service = ItServiceRequest(
|
||
id: 'r1',
|
||
services: ['wifi'],
|
||
eventName: 'Event',
|
||
status: ItServiceRequestStatus.inProgress,
|
||
outsidePremiseAllowed: false,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
);
|
||
|
||
final assignment = ItServiceRequestAssignment(
|
||
id: 'a1',
|
||
requestId: 'r1',
|
||
userId: 'u1',
|
||
createdAt: now,
|
||
);
|
||
|
||
final container = ProviderContainer(
|
||
overrides: [
|
||
profilesProvider.overrideWith((ref) => Stream.value([profile])),
|
||
itServiceRequestsProvider.overrideWith(
|
||
(ref) => Stream.value([service]),
|
||
),
|
||
itServiceRequestAssignmentsProvider.overrideWith(
|
||
(ref) => Stream.value([assignment]),
|
||
),
|
||
|
||
// everything else can be empty streams so dashboard metrics doesn't crash
|
||
ticketsProvider.overrideWith((ref) => Stream.value(const <Ticket>[])),
|
||
tasksProvider.overrideWith((ref) => Stream.value(const <Task>[])),
|
||
taskAssignmentsProvider.overrideWith(
|
||
(ref) => Stream.value(const <TaskAssignment>[]),
|
||
),
|
||
ticketMessagesAllProvider.overrideWith(
|
||
(ref) => Stream.value(const <TicketMessage>[]),
|
||
),
|
||
dutySchedulesProvider.overrideWith(
|
||
(ref) => Stream.value(const <DutySchedule>[]),
|
||
),
|
||
attendanceLogsProvider.overrideWith(
|
||
(ref) => Stream.value(const <AttendanceLog>[]),
|
||
),
|
||
livePositionsProvider.overrideWith(
|
||
(ref) => Stream.value(const <LivePosition>[]),
|
||
),
|
||
leavesProvider.overrideWith(
|
||
(ref) => Stream.value(const <LeaveOfAbsence>[]),
|
||
),
|
||
|
||
passSlipsProvider.overrideWithProvider(
|
||
StreamProvider<List<PassSlip>>(
|
||
(ref) => Stream.value(const <PassSlip>[]),
|
||
),
|
||
),
|
||
teamsProvider.overrideWithProvider(
|
||
StreamProvider<List<Team>>((ref) => Stream.value(const <Team>[])),
|
||
),
|
||
teamMembersProvider.overrideWithProvider(
|
||
StreamProvider<List<TeamMember>>(
|
||
(ref) => Stream.value(const <TeamMember>[]),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
|
||
// wait for the dashboard metrics provider to emit AsyncData
|
||
final completer = Completer<DashboardMetrics>();
|
||
container.listen<AsyncValue<DashboardMetrics>>(dashboardMetricsProvider, (
|
||
prev,
|
||
next,
|
||
) {
|
||
if (next is AsyncData<DashboardMetrics>) {
|
||
completer.complete(next.value);
|
||
}
|
||
}, fireImmediately: true);
|
||
final data = await completer.future;
|
||
expect(data.staffRows.length, 1);
|
||
expect(data.staffRows.first.status, 'On event');
|
||
});
|
||
|
||
test('service request not in progress does not affect status', () async {
|
||
AppTime.initialize();
|
||
final now = AppTime.now();
|
||
final profile = Profile(
|
||
id: 'u2',
|
||
fullName: 'Staff Two',
|
||
role: 'it_staff',
|
||
avatarUrl: null,
|
||
allowTracking: false,
|
||
);
|
||
|
||
final service = ItServiceRequest(
|
||
id: 'r2',
|
||
services: ['wifi'],
|
||
eventName: 'Event',
|
||
status: ItServiceRequestStatus.scheduled,
|
||
outsidePremiseAllowed: false,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
);
|
||
|
||
final assignment = ItServiceRequestAssignment(
|
||
id: 'a2',
|
||
requestId: 'r2',
|
||
userId: 'u2',
|
||
createdAt: now,
|
||
);
|
||
|
||
final container = ProviderContainer(
|
||
overrides: [
|
||
profilesProvider.overrideWith((ref) => Stream.value([profile])),
|
||
itServiceRequestsProvider.overrideWith(
|
||
(ref) => Stream.value([service]),
|
||
),
|
||
itServiceRequestAssignmentsProvider.overrideWith(
|
||
(ref) => Stream.value([assignment]),
|
||
),
|
||
|
||
ticketsProvider.overrideWith((ref) => Stream.value(const <Ticket>[])),
|
||
tasksProvider.overrideWith((ref) => Stream.value(const <Task>[])),
|
||
taskAssignmentsProvider.overrideWith(
|
||
(ref) => Stream.value(const <TaskAssignment>[]),
|
||
),
|
||
ticketMessagesAllProvider.overrideWith(
|
||
(ref) => Stream.value(const <TicketMessage>[]),
|
||
),
|
||
dutySchedulesProvider.overrideWith(
|
||
(ref) => Stream.value(const <DutySchedule>[]),
|
||
),
|
||
attendanceLogsProvider.overrideWith(
|
||
(ref) => Stream.value(const <AttendanceLog>[]),
|
||
),
|
||
livePositionsProvider.overrideWith(
|
||
(ref) => Stream.value(const <LivePosition>[]),
|
||
),
|
||
leavesProvider.overrideWith(
|
||
(ref) => Stream.value(const <LeaveOfAbsence>[]),
|
||
),
|
||
|
||
passSlipsProvider.overrideWithProvider(
|
||
StreamProvider<List<PassSlip>>(
|
||
(ref) => Stream.value(const <PassSlip>[]),
|
||
),
|
||
),
|
||
teamsProvider.overrideWithProvider(
|
||
StreamProvider<List<Team>>((ref) => Stream.value(const <Team>[])),
|
||
),
|
||
teamMembersProvider.overrideWithProvider(
|
||
StreamProvider<List<TeamMember>>(
|
||
(ref) => Stream.value(const <TeamMember>[]),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
|
||
final completer = Completer<DashboardMetrics>();
|
||
container.listen<AsyncValue<DashboardMetrics>>(dashboardMetricsProvider, (
|
||
prev,
|
||
next,
|
||
) {
|
||
if (next is AsyncData<DashboardMetrics>) {
|
||
completer.complete(next.value);
|
||
}
|
||
}, fireImmediately: true);
|
||
final data = await completer.future;
|
||
expect(data.staffRows.length, 1);
|
||
// since user has no attendance or task data they should default to Off duty
|
||
expect(data.staffRows.first.status.toLowerCase(), contains('off'));
|
||
});
|
||
});
|
||
|
||
test(
|
||
'approved leave should force On leave status and not mark Off duty',
|
||
() async {
|
||
AppTime.initialize();
|
||
final now = AppTime.now();
|
||
final profile = Profile(
|
||
id: 'u3',
|
||
fullName: 'Staff Three',
|
||
role: 'it_staff',
|
||
avatarUrl: null,
|
||
allowTracking: false,
|
||
);
|
||
|
||
// create a schedule for today
|
||
final schedule = DutySchedule(
|
||
id: 's1',
|
||
userId: profile.id,
|
||
shiftType: 'am',
|
||
startTime: DateTime(now.year, now.month, now.day, 8),
|
||
endTime: DateTime(now.year, now.month, now.day, 16),
|
||
status: 'scheduled',
|
||
createdAt: now,
|
||
checkInAt: null,
|
||
checkInLocation: null,
|
||
relieverIds: [],
|
||
);
|
||
|
||
final leave = LeaveOfAbsence(
|
||
id: 'l1',
|
||
userId: profile.id,
|
||
leaveType: 'sick_leave',
|
||
justification: 'ill',
|
||
startTime: now.subtract(const Duration(hours: 1)),
|
||
endTime: now.add(const Duration(hours: 4)),
|
||
status: 'approved',
|
||
filedBy: profile.id,
|
||
createdAt: now,
|
||
);
|
||
|
||
final container = ProviderContainer(
|
||
overrides: [
|
||
profilesProvider.overrideWith((ref) => Stream.value([profile])),
|
||
dutySchedulesProvider.overrideWith((ref) => Stream.value([schedule])),
|
||
leavesProvider.overrideWith((ref) => Stream.value([leave])),
|
||
|
||
// stub the rest with empties
|
||
ticketsProvider.overrideWith((ref) => Stream.value(const <Ticket>[])),
|
||
tasksProvider.overrideWith((ref) => Stream.value(const <Task>[])),
|
||
taskAssignmentsProvider.overrideWith(
|
||
(ref) => Stream.value(const <TaskAssignment>[]),
|
||
),
|
||
ticketMessagesAllProvider.overrideWith(
|
||
(ref) => Stream.value(const <TicketMessage>[]),
|
||
),
|
||
attendanceLogsProvider.overrideWith(
|
||
(ref) => Stream.value(const <AttendanceLog>[]),
|
||
),
|
||
livePositionsProvider.overrideWith(
|
||
(ref) => Stream.value(const <LivePosition>[]),
|
||
),
|
||
passSlipsProvider.overrideWithProvider(
|
||
StreamProvider<List<PassSlip>>(
|
||
(ref) => Stream.value(const <PassSlip>[]),
|
||
),
|
||
),
|
||
teamsProvider.overrideWithProvider(
|
||
StreamProvider<List<Team>>((ref) => Stream.value(const <Team>[])),
|
||
),
|
||
teamMembersProvider.overrideWithProvider(
|
||
StreamProvider<List<TeamMember>>(
|
||
(ref) => Stream.value(const <TeamMember>[]),
|
||
),
|
||
),
|
||
itServiceRequestsProvider.overrideWith(
|
||
(ref) => Stream.value(const <ItServiceRequest>[]),
|
||
),
|
||
itServiceRequestAssignmentsProvider.overrideWith(
|
||
(ref) => Stream.value(const <ItServiceRequestAssignment>[]),
|
||
),
|
||
],
|
||
);
|
||
|
||
final completer = Completer<DashboardMetrics>();
|
||
container.listen<AsyncValue<DashboardMetrics>>(dashboardMetricsProvider, (
|
||
prev,
|
||
next,
|
||
) {
|
||
if (next is AsyncData<DashboardMetrics>) {
|
||
completer.complete(next.value);
|
||
}
|
||
}, fireImmediately: true);
|
||
final data = await completer.future;
|
||
expect(data.staffRows.length, 1);
|
||
expect(data.staffRows.first.status, equals('On leave'));
|
||
},
|
||
);
|
||
|
||
test('rejected leave should not affect schedule status', () async {
|
||
AppTime.initialize();
|
||
final now = AppTime.now();
|
||
final profile = Profile(
|
||
id: 'u4',
|
||
fullName: 'Staff Four',
|
||
role: 'it_staff',
|
||
avatarUrl: null,
|
||
allowTracking: false,
|
||
);
|
||
|
||
final schedule = DutySchedule(
|
||
id: 's2',
|
||
// Anchor the shift around `now` so the shift is always active when the
|
||
// suite runs; a hard-coded 08:00–16:00 window made this test pass only
|
||
// during daytime and report 'Off duty' (correctly) at night.
|
||
userId: profile.id,
|
||
shiftType: 'am',
|
||
startTime: now.subtract(const Duration(hours: 1)),
|
||
endTime: now.add(const Duration(hours: 4)),
|
||
status: 'scheduled',
|
||
createdAt: now,
|
||
checkInAt: null,
|
||
checkInLocation: null,
|
||
relieverIds: [],
|
||
);
|
||
|
||
final leave = LeaveOfAbsence(
|
||
id: 'l2',
|
||
userId: profile.id,
|
||
leaveType: 'vacation_leave',
|
||
justification: 'plans',
|
||
startTime: now.subtract(const Duration(hours: 1)),
|
||
endTime: now.add(const Duration(hours: 4)),
|
||
status: 'rejected',
|
||
filedBy: profile.id,
|
||
createdAt: now,
|
||
);
|
||
|
||
final container = ProviderContainer(
|
||
overrides: [
|
||
profilesProvider.overrideWith((ref) => Stream.value([profile])),
|
||
dutySchedulesProvider.overrideWith((ref) => Stream.value([schedule])),
|
||
leavesProvider.overrideWith((ref) => Stream.value([leave])),
|
||
ticketsProvider.overrideWith((ref) => Stream.value(const <Ticket>[])),
|
||
tasksProvider.overrideWith((ref) => Stream.value(const <Task>[])),
|
||
taskAssignmentsProvider.overrideWith(
|
||
(ref) => Stream.value(const <TaskAssignment>[]),
|
||
),
|
||
ticketMessagesAllProvider.overrideWith(
|
||
(ref) => Stream.value(const <TicketMessage>[]),
|
||
),
|
||
attendanceLogsProvider.overrideWith(
|
||
(ref) => Stream.value(const <AttendanceLog>[]),
|
||
),
|
||
livePositionsProvider.overrideWith(
|
||
(ref) => Stream.value(const <LivePosition>[]),
|
||
),
|
||
passSlipsProvider.overrideWithProvider(
|
||
StreamProvider<List<PassSlip>>(
|
||
(ref) => Stream.value(const <PassSlip>[]),
|
||
),
|
||
),
|
||
teamsProvider.overrideWithProvider(
|
||
StreamProvider<List<Team>>((ref) => Stream.value(const <Team>[])),
|
||
),
|
||
teamMembersProvider.overrideWithProvider(
|
||
StreamProvider<List<TeamMember>>(
|
||
(ref) => Stream.value(const <TeamMember>[]),
|
||
),
|
||
),
|
||
itServiceRequestsProvider.overrideWith(
|
||
(ref) => Stream.value(const <ItServiceRequest>[]),
|
||
),
|
||
itServiceRequestAssignmentsProvider.overrideWith(
|
||
(ref) => Stream.value(const <ItServiceRequestAssignment>[]),
|
||
),
|
||
],
|
||
);
|
||
|
||
final completer = Completer<DashboardMetrics>();
|
||
container.listen<AsyncValue<DashboardMetrics>>(dashboardMetricsProvider, (
|
||
prev,
|
||
next,
|
||
) {
|
||
if (next is AsyncData<DashboardMetrics>) {
|
||
completer.complete(next.value);
|
||
}
|
||
}, fireImmediately: true);
|
||
final data = await completer.future;
|
||
expect(data.staffRows.length, 1);
|
||
// since leave was rejected, status should not be 'On leave' nor 'Off duty'
|
||
expect(data.staffRows.first.status, isNot('On leave'));
|
||
expect(data.staffRows.first.status.toLowerCase(), isNot(contains('off')));
|
||
});
|
||
}
|