import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:tasq/providers/tasks_provider.dart'; import 'package:tasq/models/task.dart'; import 'package:tasq/models/task_activity_log.dart'; import 'package:tasq/utils/app_time.dart'; // Minimal fake supabase client similar to integration test work, // only implements the methods used by TasksController. class _FakeClient { final Map>> tables = { 'tasks': [], 'task_activity_logs': [], 'task_assignments': [], 'profiles': [], }; _FakeQuery from(String table) => _FakeQuery(this, table); } /// A chainable fake query that also implements `Future>` so that /// `await client.from('t').select().eq('id', x)` returns the filtered rows /// rather than throwing a cast error. class _FakeQuery implements Future>> { _FakeQuery(this.client, this.table); final _FakeClient client; final String table; Map? _eq; Map? _insertPayload; Map? _updatePayload; String? _inFilterField; List? _inFilterValues; List> get _filteredRows { var rows = List>.from(client.tables[table] ?? []); if (_inFilterField != null && _inFilterValues != null) { rows = rows .where((r) => _inFilterValues!.contains(r[_inFilterField]?.toString())) .toList(); } if (_eq != null) { final field = _eq!.keys.first; final value = _eq![field]; rows = rows.where((r) => r[field] == value).toList(); } return rows; } // Future> delegation so `await fakeQuery` returns the list. Future>> get _asFuture => Future.value(_filteredRows); @override Stream>> asStream() => _asFuture.asStream(); @override Future>> catchError( Function onError, { bool Function(Object error)? test, }) => _asFuture.catchError(onError, test: test); @override Future then( FutureOr Function(List> value) onValue, { Function? onError, }) => _asFuture.then(onValue, onError: onError); @override Future>> timeout( Duration timeLimit, { FutureOr>> Function()? onTimeout, }) => _asFuture.timeout(timeLimit, onTimeout: onTimeout); @override Future>> whenComplete( FutureOr Function() action, ) => _asFuture.whenComplete(action); // Query builder methods _FakeQuery select([String? _]) => this; _FakeQuery inFilter(String field, List values) { _inFilterField = field; _inFilterValues = values.map((v) => v.toString()).toList(); return this; } Future?> maybeSingle() async { final rows = _filteredRows; if (rows.isEmpty) return null; return Map.from(rows.first); } _FakeQuery insert(Map payload) { _insertPayload = Map.from(payload); return this; } Future> single() async { if (_insertPayload != null) { final id = 'tsk-${client.tables['tasks']!.length + 1}'; final row = Map.from(_insertPayload!); row['id'] = id; client.tables[table]!.add(row); return Map.from(row); } throw Exception('unexpected single() call'); } _FakeQuery update(Map payload) { _updatePayload = payload; // don't apply yet; wait for eq to know which row return this; } _FakeQuery eq(String field, dynamic value) { _eq = {field: value}; // apply update payload now that we know which row to target if (_updatePayload != null) { final idx = client.tables[table]!.indexWhere((r) => r[field] == value); if (idx >= 0) { client.tables[table]![idx] = { ...client.tables[table]![idx], ..._updatePayload!, }; } // clear payload after applying so subsequent eq calls don't reapply _updatePayload = null; } return this; } } void main() { group('TasksController business rules', () { late _FakeClient fake; late TasksController controller; setUp(() { fake = _FakeClient(); controller = TasksController(fake as dynamic); // ignore: avoid_dynamic_calls // note: controller expects SupabaseClient; using dynamic bypass. }); test('cannot complete a task without required metadata', () async { // insert a task with no metadata at all final row = {'id': 'tsk-1', 'status': 'queued'}; fake.tables['tasks']!.add(row); expect( () => controller.updateTaskStatus(taskId: 'tsk-1', status: 'completed'), throwsA(isA()), ); }); test('cannot complete when action taken is missing', () async { // insert a task that has the basic request metadata but nothing else final row = { 'id': 'tsk-3', 'status': 'queued', 'request_type': 'Repair', 'request_category': 'Hardware', }; fake.tables['tasks']!.add(row); // missing actionTaken should still prevent completion even though // signatories are not required. expect( () => controller.updateTaskStatus(taskId: 'tsk-3', status: 'completed'), throwsA(isA()), ); // add some signatories but no actionTaken yet await controller.updateTask( taskId: 'tsk-3', requestedBy: 'Alice', notedBy: 'Bob', receivedBy: 'Carol', ); expect( () => controller.updateTaskStatus(taskId: 'tsk-3', status: 'completed'), throwsA(isA()), ); // once action taken is provided completion should succeed even if // signatories remain empty (they already have values here, but the // previous checks show they aren't required). // An IT staff assignment is also required before completion. fake.tables['task_assignments']! .add({'task_id': 'tsk-3', 'user_id': 'it-1'}); fake.tables['profiles']!.add({'id': 'it-1', 'role': 'it_staff'}); await controller.updateTask(taskId: 'tsk-3', actionTaken: '{}'); await controller.updateTaskStatus(taskId: 'tsk-3', status: 'completed'); expect( fake.tables['tasks']!.firstWhere((t) => t['id'] == 'tsk-3')['status'], 'completed', ); }); test('completing after adding metadata succeeds', () async { // insert a task final row = {'id': 'tsk-2', 'status': 'queued'}; fake.tables['tasks']!.add(row); // An IT staff assignment is required before completion. fake.tables['task_assignments']! .add({'task_id': 'tsk-2', 'user_id': 'it-1'}); fake.tables['profiles']!.add({'id': 'it-1', 'role': 'it_staff'}); // update metadata via updateTask including actionTaken await controller.updateTask( taskId: 'tsk-2', requestType: 'Repair', requestCategory: 'Hardware', actionTaken: '{}', ); await controller.updateTaskStatus(taskId: 'tsk-2', status: 'completed'); expect( fake.tables['tasks']!.firstWhere((t) => t['id'] == 'tsk-2')['status'], 'completed', ); }); test('Task.hasIncompleteDetails flag works correctly', () { final now = DateTime.now(); final base = Task( id: 'x', ticketId: null, taskNumber: null, title: 't', description: '', officeId: null, status: 'completed', priority: 1, queueOrder: null, createdAt: now, creatorId: null, startedAt: null, completedAt: now, // leave all optional metadata null ); expect(base.hasIncompleteDetails, isTrue); final full = Task( id: 'x', ticketId: null, taskNumber: null, title: 't', description: '', officeId: null, status: 'completed', priority: 1, queueOrder: null, createdAt: now, creatorId: null, startedAt: null, completedAt: now, requestedBy: 'A', notedBy: 'B', receivedBy: 'C', requestType: 'foo', requestCategory: 'bar', actionTaken: '{}', ); expect(full.hasIncompleteDetails, isFalse); }); }); // --------------------------------------------------------------------------- // TaskActivityLog model tests // These cover parsing edge-cases used by the deduplication fingerprint logic. // --------------------------------------------------------------------------- group('TaskActivityLog.fromMap', () { setUp(() { AppTime.initialize(); }); test('parses basic fields correctly', () { final map = { 'id': 'log-1', 'task_id': 'tsk-1', 'actor_id': 'user-1', 'action_type': 'created', 'meta': null, 'created_at': '2026-03-21T10:00:00.000Z', }; final log = TaskActivityLog.fromMap(map); expect(log.id, 'log-1'); expect(log.taskId, 'tsk-1'); expect(log.actorId, 'user-1'); expect(log.actionType, 'created'); expect(log.meta, isNull); }); test('parses meta as Map', () { final map = { 'id': 'log-2', 'task_id': 'tsk-1', 'actor_id': null, 'action_type': 'assigned', 'meta': {'user_id': 'u-1', 'role': 'it_staff'}, 'created_at': '2026-03-21T11:00:00.000Z', }; final log = TaskActivityLog.fromMap(map); expect(log.meta, {'user_id': 'u-1', 'role': 'it_staff'}); expect(log.actorId, isNull); }); test('parses meta from JSON-encoded string', () { final map = { 'id': 'log-3', 'task_id': 'tsk-2', 'actor_id': 'u-2', 'action_type': 'filled_notes', 'meta': '{"value":"hello"}', 'created_at': '2026-03-21T12:00:00.000Z', }; final log = TaskActivityLog.fromMap(map); expect(log.meta, {'value': 'hello'}); }); test('handles non-JSON meta string gracefully (meta is null)', () { final map = { 'id': 'log-4', 'task_id': 'tsk-3', 'actor_id': 'u-3', 'action_type': 'note', 'meta': 'not-json', 'created_at': '2026-03-21T13:00:00.000Z', }; final log = TaskActivityLog.fromMap(map); expect(log.meta, isNull); }); test('defaults action_type to unknown when missing', () { final map = { 'id': 'log-5', 'task_id': 'tsk-4', 'actor_id': null, 'action_type': null, 'meta': null, 'created_at': '2026-03-21T14:00:00.000Z', }; final log = TaskActivityLog.fromMap(map); expect(log.actionType, 'unknown'); }); test('id and task_id coerced to string from int', () { final map = { 'id': 42, 'task_id': 7, 'actor_id': null, 'action_type': 'started', 'meta': null, 'created_at': '2026-03-21T15:00:00.000Z', }; final log = TaskActivityLog.fromMap(map); expect(log.id, '42'); expect(log.taskId, '7'); }); test('parses created_at as DateTime directly', () { final dt = DateTime.utc(2026, 3, 21, 10, 0, 0); final map = { 'id': 'log-6', 'task_id': 'tsk-5', 'actor_id': null, 'action_type': 'completed', 'meta': null, 'created_at': dt, }; final log = TaskActivityLog.fromMap(map); expect(log.createdAt.year, 2026); expect(log.createdAt.month, 3); expect(log.createdAt.day, 21); }); test('two logs with same fields are equal by value (dedup fingerprint)', () { final base = { 'id': 'log-7', 'task_id': 'tsk-6', 'actor_id': 'u-1', 'action_type': 'filled_notes', 'meta': {'value': 'typed text'}, 'created_at': '2026-03-21T10:05:00.000Z', }; final log1 = TaskActivityLog.fromMap(base); final log2 = TaskActivityLog.fromMap(base); // Verify the fields that form the dedup fingerprint are identical. expect(log1.taskId, log2.taskId); expect(log1.actorId, log2.actorId); expect(log1.actionType, log2.actionType); expect(log1.createdAt, log2.createdAt); }); }); }