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> inserts = []; final List> updates = []; final List deletes = []; } class _FakeQuery implements Future>> { _FakeQuery(this._tables, this._table); final Map _tables; final String _table; Map? _insertPayload; Map? _updatePayload; String? _eqField; dynamic _eqValue; String? _inField; List? _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 payload) { _insertPayload = Map.from(payload); return this; } _FakeQuery update(Map payload) { _updatePayload = Map.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 values) { _inField = field; _inValues = values.map((v) => v.toString()).toList(); return this; } _FakeQuery order(String column, {bool ascending = true}) => this; Future> single() async { if (_insertPayload != null) { final row = Map.from(_insertPayload!); row['id'] = 'fake-id-${_log.inserts.length + 1}'; _log.inserts.add(row); return row; } throw Exception('_FakeQuery.single: no insert payload'); } Future?> maybeSingle() async => null; Future>> get _asFuture async { // Return rows from the in-filter for profile queries. if (_table == 'profiles' && _inValues != null) { return _inValues! .map((id) => {'id': id, 'role': 'it_staff'}) .toList(); } return const []; } @override Stream>> asStream() => _asFuture.asStream(); @override Future then( FutureOr Function(List>) onValue, { Function? onError, }) => _asFuture.then(onValue, onError: onError); @override Future>> catchError( Function onError, { bool Function(Object)? test, }) => _asFuture.catchError(onError, test: test); @override Future>> whenComplete( FutureOr Function() action) => _asFuture.whenComplete(action); @override Future>> timeout(Duration d, {FutureOr>> 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 _tables = {}; final _FakeAuth auth; _FakeQuery from(String table) => _FakeQuery(_tables, table); _TableLog tableLog(String table) => _tables.putIfAbsent(table, _TableLog.new); } class _FakeNotificationsController { final List> calls = []; Future createNotification({ required List userIds, required String type, required String actorId, Map? fields, String? pushTitle, String? pushBody, Map? 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); }); }); }