Files
sharedinbox/test/unit/apply_sieve_rules_test.dart
T

304 lines
9.1 KiB
Dart

import 'dart:convert';
import 'package:drift/drift.dart' show Value;
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/account.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 'account_repository_impl_test.dart' show MapSecureStorage;
import 'db_test_helper.dart';
// ── Helpers ───────────────────────────────────────────────────────────────────
const _account = Account(
id: 'sieve-acc',
displayName: 'Sieve Test',
email: 'sieve@example.com',
imapHost: 'imap.example.com',
smtpHost: 'smtp.example.com',
);
Future<(AppDatabase, EmailRepositoryImpl)> _makeSetup() async {
final db = openTestDatabase();
final storage = MapSecureStorage();
final accounts = AccountRepositoryImpl(db, storage);
await accounts.addAccount(_account, 'password');
final repo = EmailRepositoryImpl(db, accounts);
return (db, repo);
}
/// Inserts a minimal email row in the INBOX. Returns the row id.
Future<String> _insertInboxEmail(
AppDatabase db, {
required String id,
required String messageId,
String subject = 'Test',
String from = 'sender@example.com',
String mailboxPath = 'INBOX',
}) async {
await db.into(db.emails).insert(
EmailsCompanion.insert(
id: id,
accountId: _account.id,
mailboxPath: mailboxPath,
uid: int.parse(id.split(':').last),
subject: Value(subject),
receivedAt: DateTime.now(),
fromJson: Value(
jsonEncode([
{'name': '', 'email': from},
]),
),
messageId: Value(messageId),
),
);
// Insert a thread row so _updateThread does not throw.
await db.into(db.threads).insertOnConflictUpdate(
ThreadsCompanion.insert(
id: id,
accountId: _account.id,
mailboxPath: mailboxPath,
latestDate: DateTime.now(),
latestEmailId: id,
),
);
return id;
}
/// Creates an active Sieve script for the test account.
Future<void> _insertSieveScript(AppDatabase db, String content) async {
await db.into(db.localSieveScripts).insert(
LocalSieveScriptsCompanion.insert(
accountId: _account.id,
name: 'test-script',
content: Value(content),
isActive: const Value(true),
),
);
}
// ── Tests ─────────────────────────────────────────────────────────────────────
void main() {
setUpAll(configureSqliteForTests);
group('applySieveRules', () {
test('returns 0 when no active script exists', () async {
final (_, repo) = await _makeSetup();
expect(await repo.applySieveRules(_account.id), 0);
});
test('returns 0 when script has no matching rules', () async {
final (db, repo) = await _makeSetup();
await _insertSieveScript(db, '''
require ["fileinto"];
if header :contains "subject" ["NEVER_MATCHES_XYZ"] {
fileinto "Archive";
}
''');
await _insertInboxEmail(
db,
id: 'sieve-acc:1',
messageId: '<msg1@test>',
subject: 'Hello world',
);
expect(await repo.applySieveRules(_account.id), 0);
});
test('applies fileinto rule and queues a move pending change', () async {
final (db, repo) = await _makeSetup();
await _insertSieveScript(db, '''
require ["fileinto"];
if header :contains "subject" ["SPAM"] {
fileinto "Archive";
}
''');
await _insertInboxEmail(
db,
id: 'sieve-acc:1',
messageId: '<msg1@test>',
subject: 'THIS IS SPAM',
);
final count = await repo.applySieveRules(_account.id);
expect(count, 1);
final pending = await db.select(db.pendingChanges).get();
expect(pending, hasLength(1));
expect(pending.first.changeType, 'move');
final payload = jsonDecode(pending.first.payload) as Map<String, dynamic>;
expect(payload['dest'], 'Archive');
});
test('applies discard rule and queues a delete pending change', () async {
final (db, repo) = await _makeSetup();
await _insertSieveScript(db, '''
if header :contains "from" ["spam@evil.com"] {
discard;
}
''');
await _insertInboxEmail(
db,
id: 'sieve-acc:1',
messageId: '<msg1@test>',
from: 'spam@evil.com',
);
final count = await repo.applySieveRules(_account.id);
expect(count, 1);
final pending = await db.select(db.pendingChanges).get();
expect(pending, hasLength(1));
expect(pending.first.changeType, 'delete');
});
test('records email in LocalSieveApplied after processing', () async {
final (db, repo) = await _makeSetup();
await _insertSieveScript(db, '''
require ["fileinto"];
if header :contains "subject" ["SPAM"] {
fileinto "Archive";
}
''');
await _insertInboxEmail(
db,
id: 'sieve-acc:1',
messageId: '<msg1@test>',
subject: 'SPAM email',
);
await repo.applySieveRules(_account.id);
final applied = await db.select(db.localSieveApplied).get();
expect(applied, hasLength(1));
expect(applied.first.messageId, '<msg1@test>');
expect(applied.first.accountId, _account.id);
});
test('does not reprocess an email already in LocalSieveApplied', () async {
final (db, repo) = await _makeSetup();
await _insertSieveScript(db, '''
require ["fileinto"];
if header :contains "subject" ["SPAM"] {
fileinto "Archive";
}
''');
await _insertInboxEmail(
db,
id: 'sieve-acc:1',
messageId: '<msg1@test>',
subject: 'SPAM email',
);
// First run applies the rule.
expect(await repo.applySieveRules(_account.id), 1);
// Restore the email to INBOX to simulate a re-sync (e.g. second device
// moved it back). The LocalSieveApplied record prevents reprocessing.
await (db.update(db.emails)..where((t) => t.id.equals('sieve-acc:1')))
.write(const EmailsCompanion(mailboxPath: Value('INBOX')));
// Second run must not produce another pending change.
expect(await repo.applySieveRules(_account.id), 0);
final pending = await db.select(db.pendingChanges).get();
// Only the original move from the first run; no duplicates.
expect(pending, hasLength(1));
});
test('skips emails with no messageId', () async {
final (db, repo) = await _makeSetup();
await _insertSieveScript(db, '''
require ["fileinto"];
if header :contains "subject" ["SPAM"] {
fileinto "Archive";
}
''');
// Insert without messageId.
await db.into(db.emails).insert(
EmailsCompanion.insert(
id: 'sieve-acc:2',
accountId: _account.id,
mailboxPath: 'INBOX',
uid: 2,
subject: const Value('SPAM without id'),
receivedAt: DateTime.now(),
),
);
await db.into(db.threads).insertOnConflictUpdate(
ThreadsCompanion.insert(
id: 'sieve-acc:2',
accountId: _account.id,
mailboxPath: 'INBOX',
latestDate: DateTime.now(),
latestEmailId: 'sieve-acc:2',
),
);
expect(await repo.applySieveRules(_account.id), 0);
expect(await db.select(db.pendingChanges).get(), isEmpty);
});
test('emails not in INBOX are ignored', () async {
final (db, repo) = await _makeSetup();
await _insertSieveScript(db, '''
require ["fileinto"];
if header :contains "subject" ["SPAM"] {
fileinto "Archive";
}
''');
await _insertInboxEmail(
db,
id: 'sieve-acc:1',
messageId: '<msg1@test>',
subject: 'SPAM in Sent',
mailboxPath: 'Sent',
);
expect(await repo.applySieveRules(_account.id), 0);
expect(await db.select(db.pendingChanges).get(), isEmpty);
});
test('processes multiple emails independently', () async {
final (db, repo) = await _makeSetup();
await _insertSieveScript(db, '''
require ["fileinto"];
if header :contains "subject" ["SPAM"] {
fileinto "Archive";
}
''');
await _insertInboxEmail(
db,
id: 'sieve-acc:1',
messageId: '<msg1@test>',
subject: 'SPAM',
);
await _insertInboxEmail(
db,
id: 'sieve-acc:2',
messageId: '<msg2@test>',
subject: 'Hello',
);
await _insertInboxEmail(
db,
id: 'sieve-acc:3',
messageId: '<msg3@test>',
subject: 'More SPAM',
);
final count = await repo.applySieveRules(_account.id);
expect(count, 2);
final applied = await db.select(db.localSieveApplied).get();
// All three emails should be in LocalSieveApplied.
expect(applied, hasLength(3));
final pending = await db.select(db.pendingChanges).get();
expect(pending, hasLength(2));
});
});
}