From da97fe5f3da8ef3c36e1ba841828d4d9d8ca547a Mon Sep 17 00:00:00 2001 From: otsmr Date: Sat, 6 Jun 2026 00:46:19 +0200 Subject: [PATCH] add new test --- lib/src/database/daos/messages.dao.dart | 86 ++++++++--------- test/services/messages_purge_test.dart | 118 ++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 42 deletions(-) create mode 100644 test/services/messages_purge_test.dart diff --git a/lib/src/database/daos/messages.dao.dart b/lib/src/database/daos/messages.dao.dart index 5a9c7923..123d76ed 100644 --- a/lib/src/database/daos/messages.dao.dart +++ b/lib/src/database/daos/messages.dao.dart @@ -46,23 +46,23 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { Stream> watchMediaNotOpened(String groupId) { final query = select(messages).join([ - leftOuterJoin( - mediaFiles, - mediaFiles.mediaId.equalsExp(messages.mediaId), - ), - ]) - ..where( - mediaFiles.downloadState - .equals(DownloadState.reuploadRequested.name) - .not() & - mediaFiles.type.equals(MediaType.audio.name).not() & - messages.openedAt.isNull() & - messages.groupId.equals(groupId) & - messages.mediaId.isNotNull() & - messages.senderId.isNotNull() & - messages.type.equals(MessageType.media.name), - ) - ..orderBy([OrderingTerm.asc(messages.createdAt)]); + leftOuterJoin( + mediaFiles, + mediaFiles.mediaId.equalsExp(messages.mediaId), + ), + ]) + ..where( + mediaFiles.downloadState + .equals(DownloadState.reuploadRequested.name) + .not() & + mediaFiles.type.equals(MediaType.audio.name).not() & + messages.openedAt.isNull() & + messages.groupId.equals(groupId) & + messages.mediaId.isNotNull() & + messages.senderId.isNotNull() & + messages.type.equals(MessageType.media.name), + ) + ..orderBy([OrderingTerm.asc(messages.createdAt)]); return query.map((row) => row.readTable(messages)).watch(); } @@ -95,30 +95,31 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { milliseconds: group!.deleteMessagesAfterMilliseconds, ), ); - final query = select(messages).join([ - leftOuterJoin( - mediaFiles, - mediaFiles.mediaId.equalsExp(messages.mediaId), - ), - ]) - ..where( - messages.groupId.equals(groupId) & - (messages.openedAt.isBiggerThanValue(deletionTime) | - messages.openedAt.isNull() | - messages.mediaStored.equals(true)) & - (messages.isDeletedFromSender.equals(true) | - (messages.type.equals(MessageType.text.name).not() & - messages.type.equals(MessageType.media.name).not()) | - (messages.type.equals(MessageType.text.name) & - messages.content.isNotNull()) | - (messages.type.equals(MessageType.media.name) & - messages.mediaId.isNotNull() & - (mediaFiles.downloadState.isNull() | - mediaFiles.downloadState - .equals(DownloadState.reuploadRequested.name) - .not()))), - ) - ..orderBy([OrderingTerm.asc(messages.createdAt)]); + final query = + select(messages).join([ + leftOuterJoin( + mediaFiles, + mediaFiles.mediaId.equalsExp(messages.mediaId), + ), + ]) + ..where( + messages.groupId.equals(groupId) & + (messages.openedAt.isBiggerThanValue(deletionTime) | + messages.openedAt.isNull() | + messages.mediaStored.equals(true)) & + (messages.isDeletedFromSender.equals(true) | + (messages.type.equals(MessageType.text.name).not() & + messages.type.equals(MessageType.media.name).not()) | + (messages.type.equals(MessageType.text.name) & + messages.content.isNotNull()) | + (messages.type.equals(MessageType.media.name) & + messages.mediaId.isNotNull() & + (mediaFiles.downloadState.isNull() | + mediaFiles.downloadState + .equals(DownloadState.reuploadRequested.name) + .not()))), + ) + ..orderBy([OrderingTerm.asc(messages.createdAt)]); return query.map((row) => row.readTable(messages)).watch(); } @@ -171,7 +172,8 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { m.isDeletedFromSender.equals(true)) | m.mediaStored.equals(false)) & // Only remove the message when ALL members have seen it. Otherwise the receipt will also be deleted which could cause issues in case a member opens the image later.. - (m.openedByAll.isSmallerThanValue(deletionTime) | + ((m.openedByAll.isNotNull() & + m.openedByAll.isSmallerThanValue(deletionTime)) | (m.isDeletedFromSender.equals(true) & m.createdAt.isSmallerThanValue(deletionTime))), )) diff --git a/test/services/messages_purge_test.dart b/test/services/messages_purge_test.dart new file mode 100644 index 00000000..7a347f64 --- /dev/null +++ b/test/services/messages_purge_test.dart @@ -0,0 +1,118 @@ +import 'package:clock/clock.dart'; +import 'package:drift/drift.dart' hide isNotNull, isNull; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:twonly/locator.dart'; +import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/model/json/userdata.model.dart'; +import 'package:twonly/src/services/user.service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() async { + await locator.reset(); + locator + ..registerSingleton( + TwonlyDB.forTesting( + DatabaseConnection( + NativeDatabase.memory(), + closeStreamsSynchronously: true, + ), + ), + ) + ..registerSingleton(UserService()); + + userService.currentUser = UserData( + userId: 1, + username: 'test_user', + displayName: 'Test User', + subscriptionPlan: 'Free', + currentSetupPage: null, + appVersion: 100, + ); + userService.isUserCreated = true; + }); + + tearDown(() async { + await twonlyDB.close(); + }); + + test('purgeMessageTable preserves unopened messages and deletes expired ones', () async { + final now = clock.now(); + const retentionMs = 7200000; // 2 hours + final deletionLimit = now.subtract(const Duration(milliseconds: retentionMs)); + + // 1. Insert a group with 2 hour retention policy + await twonlyDB.groupsDao.createNewGroup( + GroupsCompanion.insert( + groupId: 'test_group', + groupName: 'Test Group', + deleteMessagesAfterMilliseconds: const Value(retentionMs), + ), + ); + + // 2. Insert test messages: + // Msg A: Unopened (openedByAll is null) + await twonlyDB.messagesDao.insertMessage( + MessagesCompanion.insert( + messageId: 'msg_a_unopened', + groupId: 'test_group', + type: 'text', + createdAt: Value(deletionLimit.subtract(const Duration(minutes: 5))), // older than deletion threshold + ), + ); + + // Msg B: Opened long ago (openedByAll is older than deletion threshold) + await twonlyDB.messagesDao.insertMessage( + MessagesCompanion.insert( + messageId: 'msg_b_opened_expired', + groupId: 'test_group', + type: 'text', + openedByAll: Value(deletionLimit.subtract(const Duration(minutes: 5))), + createdAt: Value(deletionLimit.subtract(const Duration(minutes: 30))), + ), + ); + + // Msg C: Opened recently (openedByAll is newer than deletion threshold) + await twonlyDB.messagesDao.insertMessage( + MessagesCompanion.insert( + messageId: 'msg_c_opened_recent', + groupId: 'test_group', + type: 'text', + openedByAll: Value(deletionLimit.add(const Duration(minutes: 5))), + createdAt: Value(deletionLimit.subtract(const Duration(minutes: 10))), + ), + ); + + // Msg D: Deleted from sender, older than threshold + await twonlyDB.messagesDao.insertMessage( + MessagesCompanion.insert( + messageId: 'msg_d_sender_deleted_expired', + groupId: 'test_group', + type: 'text', + isDeletedFromSender: const Value(true), + createdAt: Value(deletionLimit.subtract(const Duration(minutes: 5))), + ), + ); + + // Run purge + await twonlyDB.messagesDao.purgeMessageTable(); + + // Verify database state + final allMessages = await twonlyDB.select(twonlyDB.messages).get(); + final remainingIds = allMessages.map((m) => m.messageId).toList(); + + // msg_a_unopened should be preserved because it was never opened (openedByAll was null) + expect(remainingIds.contains('msg_a_unopened'), isTrue); + + // msg_b_opened_expired should be deleted because openedByAll < deletionLimit + expect(remainingIds.contains('msg_b_opened_expired'), isFalse); + + // msg_c_opened_recent should be preserved because openedByAll >= deletionLimit + expect(remainingIds.contains('msg_c_opened_recent'), isTrue); + + // msg_d_sender_deleted_expired should be deleted because isDeletedFromSender is true and createdAt < deletionLimit + expect(remainingIds.contains('msg_d_sender_deleted_expired'), isFalse); + }); +}