Test Cases

This commit is contained in:
2026-04-11 07:40:49 +08:00
parent f223d1f958
commit 7d8851a94a
14 changed files with 2171 additions and 92 deletions
+591
View File
@@ -0,0 +1,591 @@
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);
});
});
}