592 lines
18 KiB
Dart
592 lines
18 KiB
Dart
import 'dart:async';
|
||
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:tasq/models/announcement.dart';
|
||
import 'package:tasq/models/announcement_comment.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;
|
||
String? _eqField;
|
||
dynamic _eqValue;
|
||
String? _inField;
|
||
List<String>? _inValues;
|
||
bool _selectCalled = false;
|
||
bool _isDelete = false;
|
||
|
||
_TableLog get _log => _tables.putIfAbsent(_table, _TableLog.new);
|
||
|
||
_FakeQuery select([String? _]) {
|
||
_selectCalled = true;
|
||
return 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) {
|
||
_eqField = field;
|
||
_eqValue = 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) {
|
||
_inField = field;
|
||
_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 as dynamic,
|
||
fakeNotif as dynamic,
|
||
);
|
||
});
|
||
|
||
// ── 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);
|
||
});
|
||
});
|
||
}
|