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>
579 lines
18 KiB
Dart
579 lines
18 KiB
Dart
import 'dart:async';
|
||
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:tasq/models/announcement.model.dart';
|
||
import 'package:tasq/models/announcement_comment.model.dart';
|
||
import 'package:tasq/providers/announcements_provider.dart';
|
||
import 'package:tasq/utils/app_time.dart';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Minimal fakes for AnnouncementsController tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Tracks every call made to a table so tests can assert on them.
|
||
class _TableLog {
|
||
final List<Map<String, dynamic>> inserts = [];
|
||
final List<Map<String, dynamic>> updates = [];
|
||
final List<String> deletes = [];
|
||
}
|
||
|
||
class _FakeQuery implements Future<List<Map<String, dynamic>>> {
|
||
_FakeQuery(this._tables, this._table);
|
||
|
||
final Map<String, _TableLog> _tables;
|
||
final String _table;
|
||
|
||
Map<String, dynamic>? _insertPayload;
|
||
Map<String, dynamic>? _updatePayload;
|
||
List<String>? _inValues;
|
||
bool _isDelete = false;
|
||
|
||
_TableLog get _log => _tables.putIfAbsent(_table, _TableLog.new);
|
||
|
||
_FakeQuery select([String? _]) => this;
|
||
|
||
_FakeQuery insert(Map<String, dynamic> payload) {
|
||
_insertPayload = Map<String, dynamic>.from(payload);
|
||
return this;
|
||
}
|
||
|
||
_FakeQuery update(Map<String, dynamic> payload) {
|
||
_updatePayload = Map<String, dynamic>.from(payload);
|
||
return this;
|
||
}
|
||
|
||
_FakeQuery delete() {
|
||
_isDelete = true;
|
||
return this;
|
||
}
|
||
|
||
_FakeQuery eq(String field, dynamic value) {
|
||
if (_updatePayload != null) {
|
||
_log.updates.add({..._updatePayload!, '__eq': {field: value}});
|
||
_updatePayload = null;
|
||
} else if (_isDelete) {
|
||
_log.deletes.add(value.toString());
|
||
_isDelete = false;
|
||
}
|
||
return this;
|
||
}
|
||
|
||
_FakeQuery inFilter(String field, List<dynamic> values) {
|
||
_inValues = values.map((v) => v.toString()).toList();
|
||
return this;
|
||
}
|
||
|
||
_FakeQuery order(String column, {bool ascending = true}) => this;
|
||
|
||
Future<Map<String, dynamic>> single() async {
|
||
if (_insertPayload != null) {
|
||
final row = Map<String, dynamic>.from(_insertPayload!);
|
||
row['id'] = 'fake-id-${_log.inserts.length + 1}';
|
||
_log.inserts.add(row);
|
||
return row;
|
||
}
|
||
throw Exception('_FakeQuery.single: no insert payload');
|
||
}
|
||
|
||
Future<Map<String, dynamic>?> maybeSingle() async => null;
|
||
|
||
Future<List<Map<String, dynamic>>> get _asFuture async {
|
||
// Return rows from the in-filter for profile queries.
|
||
if (_table == 'profiles' && _inValues != null) {
|
||
return _inValues!
|
||
.map((id) => <String, dynamic>{'id': id, 'role': 'it_staff'})
|
||
.toList();
|
||
}
|
||
return const [];
|
||
}
|
||
|
||
@override
|
||
Stream<List<Map<String, dynamic>>> asStream() => _asFuture.asStream();
|
||
|
||
@override
|
||
Future<R> then<R>(
|
||
FutureOr<R> Function(List<Map<String, dynamic>>) onValue, {
|
||
Function? onError,
|
||
}) =>
|
||
_asFuture.then(onValue, onError: onError);
|
||
|
||
@override
|
||
Future<List<Map<String, dynamic>>> catchError(
|
||
Function onError, {
|
||
bool Function(Object)? test,
|
||
}) =>
|
||
_asFuture.catchError(onError, test: test);
|
||
|
||
@override
|
||
Future<List<Map<String, dynamic>>> whenComplete(
|
||
FutureOr<void> Function() action) =>
|
||
_asFuture.whenComplete(action);
|
||
|
||
@override
|
||
Future<List<Map<String, dynamic>>> timeout(Duration d,
|
||
{FutureOr<List<Map<String, dynamic>>> Function()? onTimeout}) =>
|
||
_asFuture.timeout(d, onTimeout: onTimeout);
|
||
}
|
||
|
||
class _FakeAuth {
|
||
final String? userId;
|
||
_FakeAuth(this.userId);
|
||
|
||
// Mimic `currentUser?.id`.
|
||
_FakeUser? get currentUser =>
|
||
userId != null ? _FakeUser(userId!) : null;
|
||
}
|
||
|
||
class _FakeUser {
|
||
final String id;
|
||
_FakeUser(this.id);
|
||
}
|
||
|
||
class _FakeSupabaseClient {
|
||
_FakeSupabaseClient({String? userId = 'author-1'})
|
||
: auth = _FakeAuth(userId);
|
||
|
||
final Map<String, _TableLog> _tables = {};
|
||
final _FakeAuth auth;
|
||
|
||
_FakeQuery from(String table) => _FakeQuery(_tables, table);
|
||
|
||
_TableLog tableLog(String table) =>
|
||
_tables.putIfAbsent(table, _TableLog.new);
|
||
}
|
||
|
||
class _FakeNotificationsController {
|
||
final List<Map<String, dynamic>> calls = [];
|
||
|
||
Future<void> createNotification({
|
||
required List<String> userIds,
|
||
required String type,
|
||
required String actorId,
|
||
Map<String, dynamic>? fields,
|
||
String? pushTitle,
|
||
String? pushBody,
|
||
Map<String, dynamic>? pushData,
|
||
}) async {
|
||
calls.add({
|
||
'userIds': userIds,
|
||
'type': type,
|
||
'actorId': actorId,
|
||
'fields': fields,
|
||
'pushTitle': pushTitle,
|
||
'pushBody': pushBody,
|
||
});
|
||
}
|
||
}
|
||
|
||
void main() {
|
||
setUp(() {
|
||
AppTime.initialize();
|
||
});
|
||
|
||
group('Announcement.fromMap', () {
|
||
test('parses all fields correctly', () {
|
||
final map = {
|
||
'id': 'ann-1',
|
||
'author_id': 'user-1',
|
||
'title': 'Test Announcement',
|
||
'body': 'This is a test body.',
|
||
'visible_roles': ['admin', 'dispatcher'],
|
||
'is_template': true,
|
||
'template_id': 'ann-0',
|
||
'created_at': '2026-03-21T10:00:00.000Z',
|
||
'updated_at': '2026-03-21T11:00:00.000Z',
|
||
'banner_enabled': false,
|
||
'banner_show_at': null,
|
||
'banner_hide_at': null,
|
||
'push_interval_minutes': null,
|
||
};
|
||
|
||
final announcement = Announcement.fromMap(map);
|
||
|
||
expect(announcement.id, 'ann-1');
|
||
expect(announcement.authorId, 'user-1');
|
||
expect(announcement.title, 'Test Announcement');
|
||
expect(announcement.body, 'This is a test body.');
|
||
expect(announcement.visibleRoles, ['admin', 'dispatcher']);
|
||
expect(announcement.isTemplate, true);
|
||
expect(announcement.templateId, 'ann-0');
|
||
expect(announcement.bannerEnabled, false);
|
||
expect(announcement.bannerShowAt, isNull);
|
||
expect(announcement.bannerHideAt, isNull);
|
||
expect(announcement.pushIntervalMinutes, isNull);
|
||
});
|
||
|
||
test('parses banner fields correctly', () {
|
||
final map = {
|
||
'id': 'ann-10',
|
||
'author_id': 'user-1',
|
||
'title': 'Banner Announcement',
|
||
'body': 'Body text.',
|
||
'visible_roles': ['admin'],
|
||
'is_template': false,
|
||
'template_id': null,
|
||
'created_at': '2026-03-22T08:00:00.000Z',
|
||
'updated_at': '2026-03-22T08:00:00.000Z',
|
||
'banner_enabled': true,
|
||
'banner_show_at': '2026-03-22T09:00:00.000Z',
|
||
'banner_hide_at': '2026-03-23T09:00:00.000Z',
|
||
'push_interval_minutes': 30,
|
||
};
|
||
|
||
final announcement = Announcement.fromMap(map);
|
||
|
||
expect(announcement.bannerEnabled, true);
|
||
expect(announcement.bannerShowAt, isNotNull);
|
||
expect(announcement.bannerHideAt, isNotNull);
|
||
expect(announcement.pushIntervalMinutes, 30);
|
||
});
|
||
|
||
test('defaults for missing optional fields', () {
|
||
final map = {
|
||
'id': 'ann-2',
|
||
'author_id': 'user-2',
|
||
'title': null,
|
||
'body': null,
|
||
'visible_roles': null,
|
||
'is_template': null,
|
||
'template_id': null,
|
||
'created_at': '2026-03-21T10:00:00.000Z',
|
||
'updated_at': '2026-03-21T10:00:00.000Z',
|
||
};
|
||
|
||
final announcement = Announcement.fromMap(map);
|
||
|
||
expect(announcement.title, '');
|
||
expect(announcement.body, '');
|
||
expect(announcement.visibleRoles, isEmpty);
|
||
expect(announcement.isTemplate, false);
|
||
expect(announcement.templateId, isNull);
|
||
expect(announcement.bannerEnabled, false);
|
||
expect(announcement.bannerShowAt, isNull);
|
||
expect(announcement.bannerHideAt, isNull);
|
||
expect(announcement.pushIntervalMinutes, isNull);
|
||
});
|
||
|
||
test('isBannerActive returns false when banner disabled', () {
|
||
final map = {
|
||
'id': 'ann-3',
|
||
'author_id': 'user-1',
|
||
'title': 'T',
|
||
'body': 'B',
|
||
'visible_roles': ['admin'],
|
||
'is_template': false,
|
||
'template_id': null,
|
||
'created_at': '2026-03-21T10:00:00.000Z',
|
||
'updated_at': '2026-03-21T10:00:00.000Z',
|
||
'banner_enabled': false,
|
||
'banner_show_at': null,
|
||
'banner_hide_at': null,
|
||
'push_interval_minutes': null,
|
||
};
|
||
|
||
final announcement = Announcement.fromMap(map);
|
||
expect(announcement.isBannerActive, false);
|
||
});
|
||
|
||
test('isBannerActive returns true when banner enabled with no time bounds',
|
||
() {
|
||
final map = {
|
||
'id': 'ann-4',
|
||
'author_id': 'user-1',
|
||
'title': 'T',
|
||
'body': 'B',
|
||
'visible_roles': ['admin'],
|
||
'is_template': false,
|
||
'template_id': null,
|
||
'created_at': '2026-03-21T10:00:00.000Z',
|
||
'updated_at': '2026-03-21T10:00:00.000Z',
|
||
'banner_enabled': true,
|
||
'banner_show_at': null,
|
||
'banner_hide_at': null,
|
||
'push_interval_minutes': null,
|
||
};
|
||
|
||
final announcement = Announcement.fromMap(map);
|
||
expect(announcement.isBannerActive, true);
|
||
});
|
||
|
||
test('isBannerActive returns false when hide_at is in the past', () {
|
||
final map = {
|
||
'id': 'ann-5',
|
||
'author_id': 'user-1',
|
||
'title': 'T',
|
||
'body': 'B',
|
||
'visible_roles': ['admin'],
|
||
'is_template': false,
|
||
'template_id': null,
|
||
'created_at': '2026-03-21T10:00:00.000Z',
|
||
'updated_at': '2026-03-21T10:00:00.000Z',
|
||
'banner_enabled': true,
|
||
'banner_show_at': null,
|
||
// hide_at is in the past (2000-01-01)
|
||
'banner_hide_at': '2000-01-01T00:00:00.000Z',
|
||
'push_interval_minutes': 60,
|
||
};
|
||
|
||
final announcement = Announcement.fromMap(map);
|
||
expect(announcement.isBannerActive, false);
|
||
});
|
||
|
||
test('isBannerActive returns false when show_at is in the future', () {
|
||
final farFuture = DateTime.now().add(const Duration(days: 365));
|
||
final map = {
|
||
'id': 'ann-6',
|
||
'author_id': 'user-1',
|
||
'title': 'T',
|
||
'body': 'B',
|
||
'visible_roles': ['admin'],
|
||
'is_template': false,
|
||
'template_id': null,
|
||
'created_at': '2026-03-21T10:00:00.000Z',
|
||
'updated_at': '2026-03-21T10:00:00.000Z',
|
||
'banner_enabled': true,
|
||
'banner_show_at': farFuture.toUtc().toIso8601String(),
|
||
'banner_hide_at': null,
|
||
'push_interval_minutes': null,
|
||
};
|
||
|
||
final announcement = Announcement.fromMap(map);
|
||
expect(announcement.isBannerActive, false);
|
||
});
|
||
|
||
test('equality works correctly', () {
|
||
final map = {
|
||
'id': 'ann-7',
|
||
'author_id': 'user-3',
|
||
'title': 'Same',
|
||
'body': 'Body',
|
||
'visible_roles': ['admin'],
|
||
'is_template': false,
|
||
'template_id': null,
|
||
'created_at': '2026-03-21T10:00:00.000Z',
|
||
'updated_at': '2026-03-21T10:00:00.000Z',
|
||
'banner_enabled': true,
|
||
'banner_show_at': null,
|
||
'banner_hide_at': null,
|
||
'push_interval_minutes': 60,
|
||
};
|
||
|
||
final a1 = Announcement.fromMap(map);
|
||
final a2 = Announcement.fromMap(map);
|
||
|
||
expect(a1, equals(a2));
|
||
expect(a1.hashCode, equals(a2.hashCode));
|
||
});
|
||
});
|
||
|
||
group('AnnouncementComment.fromMap', () {
|
||
test('parses all fields correctly', () {
|
||
final map = {
|
||
'id': 'comment-1',
|
||
'announcement_id': 'ann-1',
|
||
'author_id': 'user-5',
|
||
'body': 'Great announcement!',
|
||
'created_at': '2026-03-21T12:30:00.000Z',
|
||
};
|
||
|
||
final comment = AnnouncementComment.fromMap(map);
|
||
|
||
expect(comment.id, 'comment-1');
|
||
expect(comment.announcementId, 'ann-1');
|
||
expect(comment.authorId, 'user-5');
|
||
expect(comment.body, 'Great announcement!');
|
||
});
|
||
|
||
test('defaults body to empty string when null', () {
|
||
final map = {
|
||
'id': 'comment-2',
|
||
'announcement_id': 'ann-1',
|
||
'author_id': 'user-6',
|
||
'body': null,
|
||
'created_at': '2026-03-21T12:30:00.000Z',
|
||
};
|
||
|
||
final comment = AnnouncementComment.fromMap(map);
|
||
|
||
expect(comment.body, '');
|
||
});
|
||
|
||
test('equality works correctly', () {
|
||
final map = {
|
||
'id': 'comment-3',
|
||
'announcement_id': 'ann-2',
|
||
'author_id': 'user-7',
|
||
'body': 'Test',
|
||
'created_at': '2026-03-21T12:30:00.000Z',
|
||
};
|
||
|
||
final c1 = AnnouncementComment.fromMap(map);
|
||
final c2 = AnnouncementComment.fromMap(map);
|
||
|
||
expect(c1, equals(c2));
|
||
expect(c1.hashCode, equals(c2.hashCode));
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// AnnouncementsController tests
|
||
// ---------------------------------------------------------------------------
|
||
group('AnnouncementsController', () {
|
||
late _FakeSupabaseClient fakeClient;
|
||
late _FakeNotificationsController fakeNotif;
|
||
late AnnouncementsController controller;
|
||
|
||
setUp(() {
|
||
AppTime.initialize();
|
||
fakeClient = _FakeSupabaseClient(userId: 'author-1');
|
||
fakeNotif = _FakeNotificationsController();
|
||
controller = AnnouncementsController(fakeClient, fakeNotif);
|
||
});
|
||
|
||
// ── createAnnouncement – template guard ────────────────────────────
|
||
|
||
test('createAnnouncement with isTemplate=true does NOT send notifications',
|
||
() async {
|
||
await controller.createAnnouncement(
|
||
title: 'Template Title',
|
||
body: 'Body',
|
||
visibleRoles: ['admin'],
|
||
isTemplate: true,
|
||
);
|
||
|
||
expect(fakeNotif.calls, isEmpty,
|
||
reason: 'Templates are drafts; no push should be sent.');
|
||
});
|
||
|
||
// ── createAnnouncement – banner+interval guard ─────────────────────
|
||
|
||
test(
|
||
'createAnnouncement with bannerEnabled=true and pushIntervalMinutes set '
|
||
'does NOT send a creation push', () async {
|
||
await controller.createAnnouncement(
|
||
title: 'Banner Announcement',
|
||
body: 'Body',
|
||
visibleRoles: ['admin'],
|
||
isTemplate: false,
|
||
bannerEnabled: true,
|
||
pushIntervalMinutes: 30,
|
||
);
|
||
|
||
expect(fakeNotif.calls, isEmpty,
|
||
reason:
|
||
'The banner scheduler owns the first push; no creation push '
|
||
'should be sent when a push interval is configured.');
|
||
});
|
||
|
||
// ── createAnnouncement – normal announcement sends notification ────
|
||
|
||
test(
|
||
'createAnnouncement with isTemplate=false and no push interval '
|
||
'DOES send a notification', () async {
|
||
await controller.createAnnouncement(
|
||
title: 'Real Announcement',
|
||
body: 'Body',
|
||
visibleRoles: ['admin'],
|
||
isTemplate: false,
|
||
bannerEnabled: false,
|
||
);
|
||
|
||
// The fake profiles table returns one user per inFilter value.
|
||
// Our fakeClient returns profile rows whose ids are the inValues themselves,
|
||
// which here is 'admin' (the role string, not a user id), so we end up
|
||
// with one entry. The important thing is that createNotification is called.
|
||
expect(fakeNotif.calls, isNotEmpty,
|
||
reason: 'A regular announcement should trigger a push notification.');
|
||
expect(fakeNotif.calls.first['type'], 'announcement');
|
||
expect(fakeNotif.calls.first['pushTitle'], 'New Announcement');
|
||
});
|
||
|
||
// ── createAnnouncement – banner enabled but NO interval sends push ─
|
||
|
||
test(
|
||
'createAnnouncement with bannerEnabled=true but no pushIntervalMinutes '
|
||
'DOES send a notification', () async {
|
||
await controller.createAnnouncement(
|
||
title: 'Banner No Interval',
|
||
body: 'Body',
|
||
visibleRoles: ['it_staff'],
|
||
isTemplate: false,
|
||
bannerEnabled: true,
|
||
// pushIntervalMinutes intentionally omitted → null
|
||
);
|
||
|
||
expect(fakeNotif.calls, isNotEmpty,
|
||
reason:
|
||
'Banner without an interval still gets the one-time creation push.');
|
||
});
|
||
|
||
// ── dismissBanner ──────────────────────────────────────────────────
|
||
|
||
test('dismissBanner records an update with banner_hide_at set to now',
|
||
() async {
|
||
final before = DateTime.now().toUtc();
|
||
|
||
await controller.dismissBanner('ann-99');
|
||
|
||
final log = fakeClient.tableLog('announcements');
|
||
expect(log.updates, isNotEmpty);
|
||
|
||
final update = log.updates.first;
|
||
expect(update.containsKey('banner_hide_at'), isTrue,
|
||
reason: 'dismissBanner must set banner_hide_at.');
|
||
expect(update['__eq'], {'id': 'ann-99'});
|
||
|
||
// The value should be a recent ISO timestamp (within a few seconds).
|
||
final hideAt = DateTime.parse(update['banner_hide_at'] as String);
|
||
final after = DateTime.now().toUtc().add(const Duration(seconds: 2));
|
||
expect(hideAt.isAfter(before.subtract(const Duration(seconds: 2))),
|
||
isTrue);
|
||
expect(hideAt.isBefore(after), isTrue);
|
||
});
|
||
|
||
// ── deleteAnnouncement ─────────────────────────────────────────────
|
||
|
||
test('deleteAnnouncement issues a delete on the correct id', () async {
|
||
await controller.deleteAnnouncement('ann-42');
|
||
|
||
// The fake records the eq value as a delete entry.
|
||
final log = fakeClient.tableLog('announcements');
|
||
expect(log.deletes, contains('ann-42'));
|
||
});
|
||
|
||
// ── updateAnnouncement ─────────────────────────────────────────────
|
||
|
||
test('updateAnnouncement records an update with supplied fields', () async {
|
||
await controller.updateAnnouncement(
|
||
id: 'ann-10',
|
||
title: 'Updated Title',
|
||
body: 'Updated Body',
|
||
visibleRoles: ['admin', 'dispatcher'],
|
||
);
|
||
|
||
final log = fakeClient.tableLog('announcements');
|
||
expect(log.updates, isNotEmpty);
|
||
final update = log.updates.first;
|
||
expect(update['title'], 'Updated Title');
|
||
expect(update['body'], 'Updated Body');
|
||
expect(update['__eq'], {'id': 'ann-10'});
|
||
});
|
||
|
||
test('updateAnnouncement with clearBannerShowAt sets banner_show_at to null',
|
||
() async {
|
||
await controller.updateAnnouncement(
|
||
id: 'ann-11',
|
||
title: 'T',
|
||
body: 'B',
|
||
visibleRoles: [],
|
||
clearBannerShowAt: true,
|
||
);
|
||
|
||
final log = fakeClient.tableLog('announcements');
|
||
final update = log.updates.first;
|
||
expect(update.containsKey('banner_show_at'), isTrue);
|
||
expect(update['banner_show_at'], isNull);
|
||
});
|
||
});
|
||
}
|