Two fixes for the UndoLog: 1. Don't delete the original undo log entry when undo is performed. The entry stays in the log alongside the new inverse action, so the user can retry the undo if it was silently reverted by an IMAP sync. 2. Fix IMAP UID mismatch: after an IMAP move is applied on the server the email gets a new UID in the destination folder. The undo service now looks up the email by its RFC 2822 Message-ID when the original row is gone, so the reverse-move pending change carries the correct UID and actually succeeds on the server. Add findEmailByMessageId to EmailRepository interface and impl. Add a regression test that simulates the UID change scenario. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
358 lines
12 KiB
Dart
358 lines
12 KiB
Dart
import 'dart:convert';
|
|
import 'package:drift/drift.dart' show Value;
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:sharedinbox/core/models/account.dart';
|
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
|
import 'package:sharedinbox/data/db/database.dart' hide Account;
|
|
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
|
|
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
|
|
import 'package:sharedinbox/data/repositories/undo_repository_impl.dart';
|
|
import 'package:sharedinbox/di.dart';
|
|
|
|
import 'account_repository_impl_test.dart' show MapSecureStorage;
|
|
import 'db_test_helper.dart';
|
|
|
|
void main() {
|
|
late AppDatabase db;
|
|
late EmailRepositoryImpl repo;
|
|
late AccountRepositoryImpl accounts;
|
|
late ProviderContainer container;
|
|
|
|
setUpAll(() {
|
|
configureSqliteForTests();
|
|
});
|
|
|
|
setUp(() async {
|
|
db = openTestDatabase();
|
|
accounts = AccountRepositoryImpl(db, MapSecureStorage());
|
|
repo = EmailRepositoryImpl(db, accounts);
|
|
|
|
container = ProviderContainer(
|
|
overrides: [
|
|
dbProvider.overrideWithValue(db),
|
|
accountRepositoryProvider.overrideWithValue(accounts),
|
|
emailRepositoryProvider.overrideWithValue(repo),
|
|
undoRepositoryProvider.overrideWithValue(UndoRepositoryImpl(db)),
|
|
],
|
|
);
|
|
|
|
// Setup IMAP account
|
|
const account = Account(
|
|
id: 'acc1',
|
|
displayName: 'Alice',
|
|
email: 'alice@example.com',
|
|
imapHost: 'imap.example.com',
|
|
smtpHost: 'smtp.example.com',
|
|
);
|
|
await accounts.addAccount(account, 'password');
|
|
|
|
// Setup Inbox and Trash mailboxes
|
|
await db.into(db.mailboxes).insert(
|
|
MailboxesCompanion.insert(
|
|
id: 'acc1:INBOX',
|
|
accountId: 'acc1',
|
|
path: 'INBOX',
|
|
name: 'Inbox',
|
|
),
|
|
);
|
|
await db.into(db.mailboxes).insert(
|
|
MailboxesCompanion.insert(
|
|
id: 'acc1:Trash',
|
|
accountId: 'acc1',
|
|
path: 'Trash',
|
|
name: 'Trash',
|
|
role: const Value('trash'),
|
|
),
|
|
);
|
|
|
|
// Setup an email in Inbox
|
|
await db.into(db.emails).insert(
|
|
EmailsCompanion.insert(
|
|
id: 'acc1:101',
|
|
accountId: 'acc1',
|
|
mailboxPath: 'INBOX',
|
|
uid: 101,
|
|
subject: const Value('Test Email'),
|
|
receivedAt: DateTime.now(),
|
|
),
|
|
);
|
|
});
|
|
|
|
tearDown(() async {
|
|
await db.close();
|
|
container.dispose();
|
|
});
|
|
|
|
test('Undo deletion fails for IMAP (Reproduction)', () async {
|
|
const emailId = 'acc1:101';
|
|
|
|
// 0. Fetch BEFORE deleting
|
|
final original = await repo.getEmail(emailId);
|
|
|
|
// 1. Delete the email
|
|
await repo.deleteEmail(emailId);
|
|
|
|
// Verify it moved from INBOX (locally deleted for IMAP move)
|
|
final inInbox = await (db.select(db.emails)
|
|
..where((t) => t.id.equals(emailId))
|
|
..where((t) => t.mailboxPath.equals('INBOX')))
|
|
.get();
|
|
expect(inInbox, isEmpty, reason: 'Email should be gone from Inbox');
|
|
|
|
// 2. Push undo action and undo
|
|
final action = UndoAction(
|
|
id: 'undo1',
|
|
accountId: 'acc1',
|
|
type: UndoType.delete,
|
|
emailIds: [emailId],
|
|
sourceMailboxPath: 'INBOX',
|
|
originalEmails: [original!],
|
|
);
|
|
await container.read(undoServiceProvider.notifier).pushAction(action);
|
|
await container.read(undoServiceProvider.notifier).undo();
|
|
|
|
// 3. Verify it is back in Inbox
|
|
final restored = await (db.select(db.emails)
|
|
..where((t) => t.id.equals(emailId))
|
|
..where((t) => t.mailboxPath.equals('INBOX')))
|
|
.get();
|
|
|
|
expect(
|
|
restored,
|
|
isNotEmpty,
|
|
reason: 'Email should be restored to Inbox after undo',
|
|
);
|
|
});
|
|
|
|
test('Undo deletion works for JMAP', () async {
|
|
const emailId = 'jmap1:e101';
|
|
|
|
// Setup JMAP account
|
|
const jmapAccount = Account(
|
|
id: 'jmap1',
|
|
displayName: 'Alice JMAP',
|
|
email: 'alice@example.com',
|
|
type: AccountType.jmap,
|
|
jmapUrl: 'https://jmap.example.com/',
|
|
imapHost: 'imap.example.com',
|
|
smtpHost: 'smtp.example.com',
|
|
);
|
|
await accounts.addAccount(jmapAccount, 'password');
|
|
|
|
// Setup Inbox and Trash mailboxes for JMAP
|
|
await db.into(db.mailboxes).insert(
|
|
MailboxesCompanion.insert(
|
|
id: 'jmap1:INBOX',
|
|
accountId: 'jmap1',
|
|
path: 'INBOX',
|
|
name: 'Inbox',
|
|
role: const Value('inbox'),
|
|
),
|
|
);
|
|
await db.into(db.mailboxes).insert(
|
|
MailboxesCompanion.insert(
|
|
id: 'jmap1:Trash',
|
|
accountId: 'jmap1',
|
|
path: 'Trash',
|
|
name: 'Trash',
|
|
role: const Value('trash'),
|
|
),
|
|
);
|
|
|
|
// Setup an email in JMAP Inbox
|
|
await db.into(db.emails).insert(
|
|
EmailsCompanion.insert(
|
|
id: emailId,
|
|
accountId: 'jmap1',
|
|
mailboxPath: 'INBOX',
|
|
uid: 0, // not used for JMAP ID
|
|
subject: const Value('JMAP Test Email'),
|
|
receivedAt: DateTime.now(),
|
|
),
|
|
);
|
|
|
|
// 1. Delete the email
|
|
await repo.deleteEmail(emailId);
|
|
|
|
// Verify it moved to Trash locally (JMAP moveEmail updates mailboxPath)
|
|
final inTrash = await (db.select(db.emails)
|
|
..where((t) => t.id.equals(emailId))
|
|
..where((t) => t.mailboxPath.equals('Trash')))
|
|
.get();
|
|
expect(inTrash, isNotEmpty, reason: 'Email should be in Trash');
|
|
|
|
// 2. Push undo action and undo
|
|
final action = UndoAction(
|
|
id: 'undo2',
|
|
accountId: 'jmap1',
|
|
type: UndoType.delete,
|
|
emailIds: [emailId],
|
|
sourceMailboxPath: 'INBOX',
|
|
);
|
|
await container.read(undoServiceProvider.notifier).pushAction(action);
|
|
await container.read(undoServiceProvider.notifier).undo();
|
|
|
|
// 3. Verify it is back in Inbox
|
|
final restored = await (db.select(db.emails)
|
|
..where((t) => t.id.equals(emailId))
|
|
..where((t) => t.mailboxPath.equals('INBOX')))
|
|
.get();
|
|
expect(
|
|
restored,
|
|
isNotEmpty,
|
|
reason: 'JMAP email should be restored to Inbox after undo',
|
|
);
|
|
});
|
|
|
|
test(
|
|
'Undo deletion for IMAP enqueues reverse move if cancel fails',
|
|
() async {
|
|
const emailId = 'acc1:101';
|
|
final original = await repo.getEmail(emailId);
|
|
|
|
// 1. Delete
|
|
final destPath = await repo.deleteEmail(emailId);
|
|
expect(destPath, 'Trash');
|
|
|
|
// 2. Mark the pending change as "attempted" so it cannot be cancelled
|
|
await (db.update(db.pendingChanges)
|
|
..where((t) => t.resourceId.equals(emailId)))
|
|
.write(const PendingChangesCompanion(attempts: Value(1)));
|
|
|
|
// 3. Undo
|
|
final action = UndoAction(
|
|
id: 'undo3',
|
|
accountId: 'acc1',
|
|
type: UndoType.delete,
|
|
emailIds: [emailId],
|
|
sourceMailboxPath: 'INBOX',
|
|
destinationMailboxPath: destPath,
|
|
originalEmails: [original!],
|
|
);
|
|
await container.read(undoServiceProvider.notifier).pushAction(action);
|
|
await container.read(undoServiceProvider.notifier).undo();
|
|
|
|
// 4. Verify local state
|
|
final restored = await (db.select(db.emails)
|
|
..where((t) => t.id.equals(emailId))
|
|
..where((t) => t.mailboxPath.equals('INBOX')))
|
|
.get();
|
|
expect(restored, isNotEmpty);
|
|
|
|
// 5. Verify a NEW pending change was enqueued (Trash -> INBOX)
|
|
final changes = await db.select(db.pendingChanges).get();
|
|
final reverseMove = changes.firstWhere(
|
|
(c) => c.changeType == 'move' && c.attempts == 0,
|
|
);
|
|
final payload = jsonDecode(reverseMove.payload) as Map<String, dynamic>;
|
|
expect(payload['mailboxPath'], 'Trash');
|
|
expect(payload['dest'], 'INBOX');
|
|
},
|
|
);
|
|
|
|
test(
|
|
'Undo deletion for IMAP succeeds after sync assigned new UID (message-id lookup)',
|
|
() async {
|
|
const oldEmailId = 'acc1:101';
|
|
final original = await repo.getEmail(oldEmailId);
|
|
expect(original, isNotNull);
|
|
expect(original!.messageId, isNull); // set a messageId so lookup works
|
|
|
|
// Seed a messageId so undo can find the email after UID change.
|
|
await (db.update(db.emails)..where((t) => t.id.equals(oldEmailId)))
|
|
.write(const EmailsCompanion(messageId: Value('msg-101@test')));
|
|
|
|
final originalWithMsgId = await repo.getEmail(oldEmailId);
|
|
|
|
// 1. Delete → moves to Trash locally (uid=101, id='acc1:101')
|
|
final destPath = await repo.deleteEmail(oldEmailId);
|
|
expect(destPath, 'Trash');
|
|
|
|
// 2. Simulate IMAP sync: the server assigned a new UID (205) in Trash.
|
|
// The old row (acc1:101) is removed and a new row (acc1:205) is inserted.
|
|
await (db.delete(db.emails)..where((t) => t.id.equals(oldEmailId))).go();
|
|
await db.into(db.emails).insert(
|
|
EmailsCompanion.insert(
|
|
id: 'acc1:205',
|
|
accountId: 'acc1',
|
|
mailboxPath: 'Trash',
|
|
uid: 205,
|
|
subject: const Value('Test Email'),
|
|
receivedAt: DateTime.now(),
|
|
messageId: const Value('msg-101@test'),
|
|
),
|
|
);
|
|
|
|
// Mark the original pending change as already applied (cannot cancel).
|
|
await (db.update(db.pendingChanges)
|
|
..where((t) => t.resourceId.equals(oldEmailId)))
|
|
.write(const PendingChangesCompanion(attempts: Value(1)));
|
|
|
|
// 3. Undo using the old email id — undo must locate 'acc1:205' by message-id.
|
|
final action = UndoAction(
|
|
id: 'undo-uid-change',
|
|
accountId: 'acc1',
|
|
type: UndoType.delete,
|
|
emailIds: [oldEmailId],
|
|
sourceMailboxPath: 'INBOX',
|
|
destinationMailboxPath: destPath,
|
|
originalEmails: [originalWithMsgId!],
|
|
);
|
|
await container.read(undoServiceProvider.notifier).pushAction(action);
|
|
await container.read(undoServiceProvider.notifier).undo();
|
|
|
|
// 4. Verify the current email row is now in INBOX.
|
|
final inInbox = await (db.select(db.emails)
|
|
..where((t) => t.mailboxPath.equals('INBOX')))
|
|
.get();
|
|
expect(
|
|
inInbox,
|
|
isNotEmpty,
|
|
reason: 'Email should be in INBOX after undo',
|
|
);
|
|
|
|
// 5. Verify the pending change uses the new UID (205), not the old one (101).
|
|
final changes = await db.select(db.pendingChanges).get();
|
|
final reverseMove = changes.firstWhere(
|
|
(c) => c.changeType == 'move' && c.attempts == 0,
|
|
);
|
|
final payload = jsonDecode(reverseMove.payload) as Map<String, dynamic>;
|
|
expect(payload['uid'], 205, reason: 'Pending move must use the new UID');
|
|
expect(payload['dest'], 'INBOX');
|
|
},
|
|
);
|
|
|
|
test('Undo snooze clears snooze metadata and moves back', () async {
|
|
const emailId = 'acc1:101';
|
|
final original = await repo.getEmail(emailId);
|
|
|
|
// 1. Snooze
|
|
final until = DateTime(2026, 5, 10, 15);
|
|
await repo.snoozeEmail(emailId, until);
|
|
|
|
// Verify it snoozed locally
|
|
var email = await repo.getEmail(emailId);
|
|
expect(email!.mailboxPath, 'Snoozed');
|
|
expect(email.snoozedUntil, until);
|
|
|
|
// 2. Undo
|
|
final action = UndoAction(
|
|
id: 'undo4',
|
|
accountId: 'acc1',
|
|
type: UndoType.snooze,
|
|
emailIds: [emailId],
|
|
sourceMailboxPath: 'INBOX',
|
|
originalEmails: [original!],
|
|
);
|
|
await container.read(undoServiceProvider.notifier).pushAction(action);
|
|
await container.read(undoServiceProvider.notifier).undo();
|
|
|
|
// 3. Verify it is back in Inbox and metadata is cleared
|
|
email = await repo.getEmail(emailId);
|
|
expect(email!.mailboxPath, 'INBOX');
|
|
expect(email.snoozedUntil, isNull);
|
|
expect(email.snoozedFromMailboxPath, isNull);
|
|
});
|
|
}
|