tasq/test/announcements_test.dart

592 lines
18 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
});
});
}