diff --git a/lib/app/v3/db/task_database.dart b/lib/app/v3/db/task_database.dart index dc1b97b2..db1f9685 100644 --- a/lib/app/v3/db/task_database.dart +++ b/lib/app/v3/db/task_database.dart @@ -251,24 +251,46 @@ class TaskDatabase { await ensureDatabaseIsOpen(); debugPrint('task in saveEditedTaskInDB: $uuid with due $newDue'); - await _database!.update( - 'Tasks', - { - 'description': newDescription, - 'project': newProject, - 'status': newStatus, - 'priority': newPriority, - 'due': newDue, - 'modified': DateTime.now().toIso8601String(), - }, - where: 'uuid = ?', - whereArgs: [uuid], - ); - debugPrint('task${uuid}edited'); - if (newTags.isNotEmpty) { - TaskForC? task = await getTaskByUuid(uuid); - await setTagsForTask(uuid, task?.id ?? 0, newTags.toList()); - } + // Keep task fields and tag replacement atomic so a crash between the + // former update() and setTagsForTask() cannot leave stale tags. + await _database!.transaction((txn) async { + await txn.update( + 'Tasks', + { + 'description': newDescription, + 'project': newProject, + 'status': newStatus, + 'priority': newPriority, + 'due': newDue, + 'modified': DateTime.now().toIso8601String(), + }, + where: 'uuid = ?', + whereArgs: [uuid], + ); + debugPrint('task${uuid}edited'); + final taskMaps = await txn.query( + 'Tasks', + columns: ['id'], + where: 'uuid = ?', + whereArgs: [uuid], + limit: 1, + ); + final taskId = + taskMaps.isNotEmpty ? (taskMaps.first['id'] as int? ?? 0) : 0; + await txn.delete( + 'Tags', + where: 'task_uuid = ? AND task_id = ?', + whereArgs: [uuid, taskId], + ); + for (final tag in newTags) { + if (tag.trim().isNotEmpty) { + await txn.insert( + 'Tags', + {'name': tag, 'task_uuid': uuid, 'task_id': taskId}, + ); + } + } + }); } Future> findTasksWithoutUUIDs() async { @@ -322,7 +344,10 @@ class TaskDatabase { } Future close() async { - await _database!.close(); + if (_database != null) { + await _database!.close(); + _database = null; + } } Future deleteTask({description, due, project, priority}) async { diff --git a/test/api_service_test.dart b/test/api_service_test.dart index 85d84391..66f72f52 100644 --- a/test/api_service_test.dart +++ b/test/api_service_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -24,10 +25,12 @@ void main() { databaseFactory = databaseFactoryFfi; MockClient mockClient = MockClient(); + late Directory docsDir; setUpAll(() { sqfliteFfiInit(); - + docsDir = Directory.systemTemp.createTempSync('taskwarrior_test_docs_'); + // Mock SharedPreferences plugin const MethodChannel('plugins.flutter.io/shared_preferences') .setMockMethodCallHandler((MethodCall methodCall) async { @@ -36,6 +39,20 @@ void main() { } return null; }); + + const MethodChannel('plugins.flutter.io/path_provider') + .setMockMethodCallHandler((MethodCall methodCall) async { + if (methodCall.method == 'getApplicationDocumentsDirectory') { + return docsDir.path; + } + return null; + }); + }); + + tearDownAll(() { + if (docsDir.existsSync()) { + docsDir.deleteSync(recursive: true); + } }); group('Tasks model', () { @@ -134,6 +151,10 @@ void main() { await taskDatabase.open(); }); + tearDown(() async { + await taskDatabase.close(); + }); + test('insertTask adds a task to the database', () async { final task = TaskForC( id: 1, @@ -191,5 +212,85 @@ void main() { // This will throw "Bad state: No element" when there are no tasks expect(() => taskDatabase.fetchTasksFromDatabase(), throwsStateError); }); + + test('saveEditedTaskInDB updates description and tags together', () async { + final task = TaskForC( + id: 7, + description: 'Old description', + project: 'Project 1', + status: 'pending', + uuid: 'edit-uuid', + urgency: 5.0, + priority: 'H', + due: '2024-12-31', + end: '', + entry: '2024-01-01', + modified: '2024-11-01', + tags: ['old'], + start: '', + wait: '', + rtype: '', + recur: '', + depends: [], + annotations: []); + + await taskDatabase.insertTask(task); + + await taskDatabase.saveEditedTaskInDB( + 'edit-uuid', + 'New description', + 'Project 2', + 'pending', + 'M', + '2025-01-01', + ['new-a', 'new-b'], + ); + + final edited = await taskDatabase.getTaskByUuid('edit-uuid'); + expect(edited, isNotNull); + expect(edited!.description, 'New description'); + expect(edited.project, 'Project 2'); + expect(edited.priority, 'M'); + expect(edited.due, '2025-01-01'); + expect(edited.tags, unorderedEquals(['new-a', 'new-b'])); + }); + + test('saveEditedTaskInDB clears tags when given an empty list', () async { + final task = TaskForC( + id: 8, + description: 'Tagged task', + project: 'Project 1', + status: 'pending', + uuid: 'clear-tags-uuid', + urgency: 5.0, + priority: 'H', + due: '2024-12-31', + end: '', + entry: '2024-01-01', + modified: '2024-11-01', + tags: ['keep-me-not'], + start: '', + wait: '', + rtype: '', + recur: '', + depends: [], + annotations: []); + + await taskDatabase.insertTask(task); + + await taskDatabase.saveEditedTaskInDB( + 'clear-tags-uuid', + 'Tagged task', + 'Project 1', + 'pending', + 'H', + '2024-12-31', + const [], + ); + + final edited = await taskDatabase.getTaskByUuid('clear-tags-uuid'); + expect(edited, isNotNull); + expect(edited!.tags, isEmpty); + }); }); }