Files
sharedinbox/test/unit/undo_logic_test.dart
T
Thomas SharedInboxandClaude Sonnet 4.6 69e358204d fix(undo): keep undo log entry and fix IMAP UID mismatch after sync (#81)
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>
2026-05-15 10:46:12 +02:00

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);
});
}