Test Cases
This commit is contained in:
+228
-14
@@ -1,6 +1,10 @@
|
||||
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.
|
||||
@@ -8,35 +12,91 @@ class _FakeClient {
|
||||
final Map<String, List<Map<String, dynamic>>> tables = {
|
||||
'tasks': [],
|
||||
'task_activity_logs': [],
|
||||
'task_assignments': [],
|
||||
'profiles': [],
|
||||
};
|
||||
|
||||
_FakeQuery from(String table) => _FakeQuery(this, table);
|
||||
}
|
||||
|
||||
class _FakeQuery {
|
||||
/// 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;
|
||||
|
||||
_FakeQuery(this.client, this.table);
|
||||
|
||||
_FakeQuery select([String? _]) => this;
|
||||
|
||||
Future<Map<String, dynamic>?> maybeSingle() async {
|
||||
final rows = client.tables[table] ?? [];
|
||||
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];
|
||||
for (final r in rows) {
|
||||
if (r[field] == value) {
|
||||
return Map<String, dynamic>.from(r);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
rows = rows.where((r) => r[field] == value).toList();
|
||||
}
|
||||
return rows.isEmpty ? null : Map<String, dynamic>.from(rows.first);
|
||||
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) {
|
||||
@@ -134,6 +194,10 @@ void main() {
|
||||
// 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(
|
||||
@@ -147,6 +211,11 @@ void main() {
|
||||
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',
|
||||
@@ -206,4 +275,149 @@ void main() {
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user