tasq/test/tasks_provider_test.dart

424 lines
12 KiB
Dart

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<String, List<Map<String, dynamic>>> tables = {
'tasks': [],
'task_activity_logs': [],
'task_assignments': [],
'profiles': [],
};
_FakeQuery from(String table) => _FakeQuery(this, table);
}
/// A chainable fake query that also implements `Future<List<Map>>` so that
/// `await client.from('t').select().eq('id', x)` returns the filtered rows
/// rather than throwing a cast error.
class _FakeQuery implements Future<List<Map<String, dynamic>>> {
_FakeQuery(this.client, this.table);
final _FakeClient client;
final String table;
Map<String, dynamic>? _eq;
Map<String, dynamic>? _insertPayload;
Map<String, dynamic>? _updatePayload;
String? _inFilterField;
List<String>? _inFilterValues;
List<Map<String, dynamic>> get _filteredRows {
var rows = List<Map<String, dynamic>>.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<List<Map>> delegation so `await fakeQuery` returns the list.
Future<List<Map<String, dynamic>>> get _asFuture =>
Future.value(_filteredRows);
@override
Stream<List<Map<String, dynamic>>> asStream() => _asFuture.asStream();
@override
Future<List<Map<String, dynamic>>> catchError(
Function onError, {
bool Function(Object error)? test,
}) =>
_asFuture.catchError(onError, test: test);
@override
Future<R> then<R>(
FutureOr<R> Function(List<Map<String, dynamic>> value) onValue, {
Function? onError,
}) =>
_asFuture.then(onValue, onError: onError);
@override
Future<List<Map<String, dynamic>>> timeout(
Duration timeLimit, {
FutureOr<List<Map<String, dynamic>>> Function()? onTimeout,
}) =>
_asFuture.timeout(timeLimit, onTimeout: onTimeout);
@override
Future<List<Map<String, dynamic>>> whenComplete(
FutureOr<void> Function() action,
) =>
_asFuture.whenComplete(action);
// Query builder methods
_FakeQuery select([String? _]) => this;
_FakeQuery inFilter(String field, List<dynamic> values) {
_inFilterField = field;
_inFilterValues = values.map((v) => v.toString()).toList();
return this;
}
Future<Map<String, dynamic>?> maybeSingle() async {
final rows = _filteredRows;
if (rows.isEmpty) return null;
return Map<String, dynamic>.from(rows.first);
}
_FakeQuery insert(Map<String, dynamic> payload) {
_insertPayload = Map<String, dynamic>.from(payload);
return this;
}
Future<Map<String, dynamic>> single() async {
if (_insertPayload != null) {
final id = 'tsk-${client.tables['tasks']!.length + 1}';
final row = Map<String, dynamic>.from(_insertPayload!);
row['id'] = id;
client.tables[table]!.add(row);
return Map<String, dynamic>.from(row);
}
throw Exception('unexpected single() call');
}
_FakeQuery update(Map<String, dynamic> 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<Exception>()),
);
});
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<Exception>()),
);
// 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<Exception>()),
);
// 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<String,dynamic>', () {
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);
});
});
}