311 lines
8.8 KiB
Swift
311 lines
8.8 KiB
Swift
import Testing
|
|
import Foundation
|
|
import GRDB
|
|
@testable import SyncEngine
|
|
@testable import IMAPClient
|
|
@testable import MailStore
|
|
@testable import Models
|
|
|
|
@Suite("ActionQueue")
|
|
struct ActionQueueTests {
|
|
func makeStore() throws -> MailStore {
|
|
try MailStore(dbWriter: DatabaseSetup.openInMemoryDatabase())
|
|
}
|
|
|
|
func makeAccountAndStore() throws -> (MailStore, String) {
|
|
let store = try makeStore()
|
|
let accountId = "acc1"
|
|
try store.insertAccount(AccountRecord(
|
|
id: accountId,
|
|
name: "Test",
|
|
email: "test@example.com",
|
|
imapHost: "imap.example.com",
|
|
imapPort: 993
|
|
))
|
|
return (store, accountId)
|
|
}
|
|
|
|
@Test("enqueue persists action to database")
|
|
func enqueuePersists() async throws {
|
|
let (store, accountId) = try makeAccountAndStore()
|
|
let mock = MockIMAPClient()
|
|
let queue = ActionQueue(
|
|
store: store,
|
|
accountId: accountId,
|
|
imapClientProvider: { mock }
|
|
)
|
|
|
|
let action = PendingAction(
|
|
accountId: accountId,
|
|
actionType: .setFlags,
|
|
payload: .setFlags(uid: 1, mailbox: "INBOX", add: ["\\Seen"], remove: [])
|
|
)
|
|
|
|
try await queue.enqueue(action)
|
|
|
|
let pending = try store.pendingActions(accountId: accountId)
|
|
#expect(pending.count >= 1)
|
|
let found = pending.first { $0.id == action.id }
|
|
#expect(found != nil)
|
|
#expect(found?.actionType == "setFlags")
|
|
}
|
|
|
|
@Test("flush dispatches actions and removes them from queue")
|
|
func flushDispatches() async throws {
|
|
let (store, accountId) = try makeAccountAndStore()
|
|
let mock = MockIMAPClient()
|
|
let queue = ActionQueue(
|
|
store: store,
|
|
accountId: accountId,
|
|
imapClientProvider: { mock }
|
|
)
|
|
|
|
// Persist action directly (bypass fire-and-forget dispatch)
|
|
let action = PendingAction(
|
|
accountId: accountId,
|
|
actionType: .setFlags,
|
|
payload: .setFlags(uid: 1, mailbox: "INBOX", add: ["\\Seen"], remove: [])
|
|
)
|
|
let record = await queue.persistAction(action)
|
|
try store.insertPendingAction(record)
|
|
|
|
#expect(try store.pendingActionCount(accountId: accountId) == 1)
|
|
|
|
await queue.flush()
|
|
|
|
#expect(try store.pendingActionCount(accountId: accountId) == 0)
|
|
#expect(mock.storedFlags.count == 1)
|
|
#expect(mock.storedFlags[0].uid == 1)
|
|
#expect(mock.storedFlags[0].add == ["\\Seen"])
|
|
}
|
|
|
|
@Test("flush dispatches actions in creation order (FIFO)")
|
|
func flushFIFO() async throws {
|
|
let (store, accountId) = try makeAccountAndStore()
|
|
let mock = MockIMAPClient()
|
|
let queue = ActionQueue(
|
|
store: store,
|
|
accountId: accountId,
|
|
imapClientProvider: { mock }
|
|
)
|
|
|
|
let now = Date()
|
|
let action1 = PendingAction(
|
|
id: "first",
|
|
accountId: accountId,
|
|
actionType: .setFlags,
|
|
payload: .setFlags(uid: 1, mailbox: "INBOX", add: ["\\Seen"], remove: []),
|
|
createdAt: now
|
|
)
|
|
let action2 = PendingAction(
|
|
id: "second",
|
|
accountId: accountId,
|
|
actionType: .setFlags,
|
|
payload: .setFlags(uid: 2, mailbox: "INBOX", add: ["\\Flagged"], remove: []),
|
|
createdAt: now.addingTimeInterval(1)
|
|
)
|
|
|
|
try store.insertPendingAction(await queue.persistAction(action1))
|
|
try store.insertPendingAction(await queue.persistAction(action2))
|
|
|
|
await queue.flush()
|
|
|
|
#expect(mock.storedFlags.count == 2)
|
|
#expect(mock.storedFlags[0].uid == 1)
|
|
#expect(mock.storedFlags[1].uid == 2)
|
|
}
|
|
|
|
@Test("pendingCount reflects queue state")
|
|
func pendingCountReflectsState() async throws {
|
|
let (store, accountId) = try makeAccountAndStore()
|
|
let mock = MockIMAPClient()
|
|
let queue = ActionQueue(
|
|
store: store,
|
|
accountId: accountId,
|
|
imapClientProvider: { mock }
|
|
)
|
|
|
|
#expect(await queue.pendingCount == 0)
|
|
|
|
let action = PendingAction(
|
|
accountId: accountId,
|
|
actionType: .move,
|
|
payload: .move(uid: 1, from: "INBOX", to: "Archive")
|
|
)
|
|
let record = await queue.persistAction(action)
|
|
try store.insertPendingAction(record)
|
|
|
|
#expect(await queue.pendingCount == 1)
|
|
|
|
await queue.flush()
|
|
|
|
#expect(await queue.pendingCount == 0)
|
|
}
|
|
|
|
@Test("failed dispatch increments retryCount")
|
|
func failedDispatchRetry() async throws {
|
|
let (store, accountId) = try makeAccountAndStore()
|
|
let mock = FailingIMAPClient()
|
|
let queue = ActionQueue(
|
|
store: store,
|
|
accountId: accountId,
|
|
imapClientProvider: { mock }
|
|
)
|
|
|
|
let action = PendingAction(
|
|
accountId: accountId,
|
|
actionType: .setFlags,
|
|
payload: .setFlags(uid: 1, mailbox: "INBOX", add: ["\\Seen"], remove: [])
|
|
)
|
|
let record = await queue.persistAction(action)
|
|
try store.insertPendingAction(record)
|
|
|
|
await queue.flush()
|
|
|
|
let pending = try store.pendingActions(accountId: accountId)
|
|
#expect(pending.count == 1)
|
|
#expect(pending[0].retryCount == 1)
|
|
#expect(pending[0].lastError != nil)
|
|
}
|
|
|
|
@Test("action removed after 5 failures")
|
|
func removedAfterMaxRetries() async throws {
|
|
let (store, accountId) = try makeAccountAndStore()
|
|
let mock = FailingIMAPClient()
|
|
let queue = ActionQueue(
|
|
store: store,
|
|
accountId: accountId,
|
|
imapClientProvider: { mock }
|
|
)
|
|
|
|
let action = PendingAction(
|
|
accountId: accountId,
|
|
actionType: .setFlags,
|
|
payload: .setFlags(uid: 1, mailbox: "INBOX", add: ["\\Seen"], remove: [])
|
|
)
|
|
var record = await queue.persistAction(action)
|
|
record.retryCount = 4
|
|
try store.insertPendingAction(record)
|
|
|
|
await queue.flush()
|
|
|
|
// retryCount was 4, incremented to 5 → removed
|
|
#expect(try store.pendingActionCount(accountId: accountId) == 0)
|
|
}
|
|
|
|
@Test("delete action in trash expunges instead of moving")
|
|
func deleteInTrashExpunges() async throws {
|
|
let (store, accountId) = try makeAccountAndStore()
|
|
let mock = MockIMAPClient()
|
|
let queue = ActionQueue(
|
|
store: store,
|
|
accountId: accountId,
|
|
imapClientProvider: { mock }
|
|
)
|
|
|
|
let action = PendingAction(
|
|
accountId: accountId,
|
|
actionType: .delete,
|
|
payload: .delete(uid: 5, mailbox: "Trash", trashMailbox: "Trash")
|
|
)
|
|
let record = await queue.persistAction(action)
|
|
try store.insertPendingAction(record)
|
|
|
|
await queue.flush()
|
|
|
|
// Should have stored \Deleted flag and expunged
|
|
#expect(mock.storedFlags.count == 1)
|
|
#expect(mock.storedFlags[0].add == ["\\Deleted"])
|
|
#expect(mock.expungedMailboxes == ["Trash"])
|
|
#expect(mock.movedMessages.isEmpty)
|
|
}
|
|
|
|
@Test("delete action not in trash moves to trash")
|
|
func deleteNotInTrashMoves() async throws {
|
|
let (store, accountId) = try makeAccountAndStore()
|
|
let mock = MockIMAPClient()
|
|
let queue = ActionQueue(
|
|
store: store,
|
|
accountId: accountId,
|
|
imapClientProvider: { mock }
|
|
)
|
|
|
|
let action = PendingAction(
|
|
accountId: accountId,
|
|
actionType: .delete,
|
|
payload: .delete(uid: 3, mailbox: "INBOX", trashMailbox: "Trash")
|
|
)
|
|
let record = await queue.persistAction(action)
|
|
try store.insertPendingAction(record)
|
|
|
|
await queue.flush()
|
|
|
|
#expect(mock.movedMessages.count == 1)
|
|
#expect(mock.movedMessages[0].from == "INBOX")
|
|
#expect(mock.movedMessages[0].to == "Trash")
|
|
#expect(mock.storedFlags.isEmpty)
|
|
}
|
|
|
|
@Test("move action dispatches correctly")
|
|
func moveDispatches() async throws {
|
|
let (store, accountId) = try makeAccountAndStore()
|
|
let mock = MockIMAPClient()
|
|
let queue = ActionQueue(
|
|
store: store,
|
|
accountId: accountId,
|
|
imapClientProvider: { mock }
|
|
)
|
|
|
|
let action = PendingAction(
|
|
accountId: accountId,
|
|
actionType: .move,
|
|
payload: .move(uid: 7, from: "INBOX", to: "Archive")
|
|
)
|
|
let record = await queue.persistAction(action)
|
|
try store.insertPendingAction(record)
|
|
|
|
await queue.flush()
|
|
|
|
#expect(mock.movedMessages.count == 1)
|
|
#expect(mock.movedMessages[0].uid == 7)
|
|
#expect(mock.movedMessages[0].from == "INBOX")
|
|
#expect(mock.movedMessages[0].to == "Archive")
|
|
}
|
|
}
|
|
|
|
// MARK: - FailingIMAPClient
|
|
|
|
private final class FailingIMAPClient: IMAPClientProtocol, @unchecked Sendable {
|
|
func connect() async throws {}
|
|
func disconnect() async throws {}
|
|
func listMailboxes() async throws -> [IMAPMailboxInfo] { [] }
|
|
func selectMailbox(_ name: String) async throws -> IMAPMailboxStatus {
|
|
throw FailingIMAPError.alwaysFails
|
|
}
|
|
func fetchEnvelopes(uidsGreaterThan uid: Int) async throws -> [FetchedEnvelope] { [] }
|
|
func fetchFlags(uids: ClosedRange<Int>) async throws -> [UIDFlagsPair] { [] }
|
|
func fetchBody(uid: Int) async throws -> (text: String?, html: String?) { (nil, nil) }
|
|
func fetchFullMessage(uid: Int) async throws -> String { "" }
|
|
func fetchSection(uid: Int, mailbox: String, section: String) async throws -> Data { Data() }
|
|
func storeFlags(uid: Int, mailbox: String, add: [String], remove: [String]) async throws {
|
|
throw FailingIMAPError.alwaysFails
|
|
}
|
|
func moveMessage(uid: Int, from: String, to: String) async throws {
|
|
throw FailingIMAPError.alwaysFails
|
|
}
|
|
func copyMessage(uid: Int, from: String, to: String) async throws {
|
|
throw FailingIMAPError.alwaysFails
|
|
}
|
|
func expunge(mailbox: String) async throws {
|
|
throw FailingIMAPError.alwaysFails
|
|
}
|
|
func appendMessage(to mailbox: String, message: Data, flags: [String]) async throws {
|
|
throw FailingIMAPError.alwaysFails
|
|
}
|
|
func capabilities() async throws -> Set<String> { [] }
|
|
}
|
|
|
|
private enum FailingIMAPError: Error {
|
|
case alwaysFails
|
|
}
|