423 lines
12 KiB
Swift
423 lines
12 KiB
Swift
import Testing
|
|
import GRDB
|
|
@testable import MailStore
|
|
|
|
@Suite("V2 Migrations")
|
|
struct MigrationTests {
|
|
func makeStore() throws -> MailStore {
|
|
try MailStore(dbWriter: DatabaseSetup.openInMemoryDatabase())
|
|
}
|
|
|
|
func makeAccount(_ store: MailStore) throws {
|
|
try store.insertAccount(AccountRecord(
|
|
id: "acc1", name: "Test", email: "test@example.com",
|
|
imapHost: "imap.example.com", imapPort: 993
|
|
))
|
|
}
|
|
|
|
// MARK: - Schema verification
|
|
|
|
@Test("v2_smtp migration adds SMTP columns to account table")
|
|
func smtpColumnsExist() throws {
|
|
let store = try makeStore()
|
|
try store.insertAccount(AccountRecord(
|
|
id: "acc1", name: "Test", email: "test@example.com",
|
|
imapHost: "imap.example.com", imapPort: 993,
|
|
smtpHost: "smtp.example.com", smtpPort: 465, smtpSecurity: "ssl"
|
|
))
|
|
let accounts = try store.accounts()
|
|
#expect(accounts[0].smtpHost == "smtp.example.com")
|
|
#expect(accounts[0].smtpPort == 465)
|
|
#expect(accounts[0].smtpSecurity == "ssl")
|
|
}
|
|
|
|
@Test("v2_smtp columns default to nil for backwards compatibility")
|
|
func smtpColumnsDefaultNil() throws {
|
|
let store = try makeStore()
|
|
try store.insertAccount(AccountRecord(
|
|
id: "acc1", name: "Test", email: "test@example.com",
|
|
imapHost: "imap.example.com", imapPort: 993
|
|
))
|
|
let accounts = try store.accounts()
|
|
#expect(accounts[0].smtpHost == nil)
|
|
#expect(accounts[0].smtpPort == nil)
|
|
#expect(accounts[0].smtpSecurity == nil)
|
|
}
|
|
|
|
@Test("v2_mailboxRole migration adds role column to mailbox table")
|
|
func mailboxRoleColumnExists() throws {
|
|
let store = try makeStore()
|
|
try makeAccount(store)
|
|
try store.upsertMailbox(MailboxRecord(
|
|
id: "mb1", accountId: "acc1", name: "Sent",
|
|
uidValidity: 1, uidNext: 1, role: "sent"
|
|
))
|
|
let mailboxes = try store.mailboxes(accountId: "acc1")
|
|
#expect(mailboxes[0].role == "sent")
|
|
}
|
|
|
|
@Test("mailbox role defaults to nil")
|
|
func mailboxRoleDefaultsNil() throws {
|
|
let store = try makeStore()
|
|
try makeAccount(store)
|
|
try store.upsertMailbox(MailboxRecord(
|
|
id: "mb1", accountId: "acc1", name: "INBOX",
|
|
uidValidity: 1, uidNext: 1
|
|
))
|
|
let mailboxes = try store.mailboxes(accountId: "acc1")
|
|
#expect(mailboxes[0].role == nil)
|
|
}
|
|
|
|
// MARK: - DraftRecord round-trip
|
|
|
|
@Test("DraftRecord insert and fetch round-trip")
|
|
func draftRoundTrip() throws {
|
|
let store = try makeStore()
|
|
try makeAccount(store)
|
|
|
|
let draft = DraftRecord(
|
|
id: "d1", accountId: "acc1",
|
|
inReplyTo: "msg123@example.com",
|
|
toAddresses: "[{\"address\":\"bob@example.com\"}]",
|
|
subject: "Re: Hello",
|
|
bodyText: "Thanks for the email",
|
|
createdAt: "2026-03-14T10:00:00Z",
|
|
updatedAt: "2026-03-14T10:00:00Z"
|
|
)
|
|
try store.insertDraft(draft)
|
|
|
|
let fetched = try store.draft(id: "d1")
|
|
#expect(fetched != nil)
|
|
#expect(fetched?.accountId == "acc1")
|
|
#expect(fetched?.inReplyTo == "msg123@example.com")
|
|
#expect(fetched?.subject == "Re: Hello")
|
|
#expect(fetched?.bodyText == "Thanks for the email")
|
|
}
|
|
|
|
@Test("DraftRecord update persists changes")
|
|
func draftUpdate() throws {
|
|
let store = try makeStore()
|
|
try makeAccount(store)
|
|
|
|
var draft = DraftRecord(
|
|
id: "d1", accountId: "acc1",
|
|
subject: "Draft v1",
|
|
bodyText: "Initial",
|
|
createdAt: "2026-03-14T10:00:00Z",
|
|
updatedAt: "2026-03-14T10:00:00Z"
|
|
)
|
|
try store.insertDraft(draft)
|
|
|
|
draft.subject = "Draft v2"
|
|
draft.bodyText = "Updated body"
|
|
draft.updatedAt = "2026-03-14T10:05:00Z"
|
|
try store.updateDraft(draft)
|
|
|
|
let fetched = try store.draft(id: "d1")
|
|
#expect(fetched?.subject == "Draft v2")
|
|
#expect(fetched?.bodyText == "Updated body")
|
|
}
|
|
|
|
@Test("DraftRecord delete removes record")
|
|
func draftDelete() throws {
|
|
let store = try makeStore()
|
|
try makeAccount(store)
|
|
|
|
try store.insertDraft(DraftRecord(
|
|
id: "d1", accountId: "acc1",
|
|
createdAt: "2026-03-14T10:00:00Z",
|
|
updatedAt: "2026-03-14T10:00:00Z"
|
|
))
|
|
try store.deleteDraft(id: "d1")
|
|
|
|
let fetched = try store.draft(id: "d1")
|
|
#expect(fetched == nil)
|
|
}
|
|
|
|
@Test("drafts(accountId:) returns drafts for account")
|
|
func draftsByAccount() throws {
|
|
let store = try makeStore()
|
|
try makeAccount(store)
|
|
|
|
try store.insertDraft(DraftRecord(
|
|
id: "d1", accountId: "acc1", subject: "First",
|
|
createdAt: "2026-03-14T10:00:00Z",
|
|
updatedAt: "2026-03-14T10:00:00Z"
|
|
))
|
|
try store.insertDraft(DraftRecord(
|
|
id: "d2", accountId: "acc1", subject: "Second",
|
|
createdAt: "2026-03-14T10:01:00Z",
|
|
updatedAt: "2026-03-14T10:05:00Z"
|
|
))
|
|
|
|
let drafts = try store.drafts(accountId: "acc1")
|
|
#expect(drafts.count == 2)
|
|
// ordered by updatedAt desc
|
|
#expect(drafts[0].id == "d2")
|
|
#expect(drafts[1].id == "d1")
|
|
}
|
|
|
|
// MARK: - PendingActionRecord round-trip
|
|
|
|
@Test("PendingActionRecord insert and fetch round-trip")
|
|
func pendingActionRoundTrip() throws {
|
|
let store = try makeStore()
|
|
try makeAccount(store)
|
|
|
|
let action = PendingActionRecord(
|
|
id: "pa1", accountId: "acc1",
|
|
actionType: "move",
|
|
payload: "{\"messageId\":\"m1\",\"toMailbox\":\"trash\"}",
|
|
createdAt: "2026-03-14T10:00:00Z"
|
|
)
|
|
try store.insertPendingAction(action)
|
|
|
|
let actions = try store.pendingActions(accountId: "acc1")
|
|
#expect(actions.count == 1)
|
|
#expect(actions[0].actionType == "move")
|
|
#expect(actions[0].retryCount == 0)
|
|
#expect(actions[0].lastError == nil)
|
|
}
|
|
|
|
@Test("PendingActionRecord update persists retryCount and lastError")
|
|
func pendingActionUpdate() throws {
|
|
let store = try makeStore()
|
|
try makeAccount(store)
|
|
|
|
var action = PendingActionRecord(
|
|
id: "pa1", accountId: "acc1",
|
|
actionType: "send",
|
|
payload: "{}",
|
|
createdAt: "2026-03-14T10:00:00Z"
|
|
)
|
|
try store.insertPendingAction(action)
|
|
|
|
action.retryCount = 3
|
|
action.lastError = "Connection timeout"
|
|
try store.updatePendingAction(action)
|
|
|
|
let actions = try store.pendingActions(accountId: "acc1")
|
|
#expect(actions[0].retryCount == 3)
|
|
#expect(actions[0].lastError == "Connection timeout")
|
|
}
|
|
|
|
@Test("PendingActionRecord delete removes record")
|
|
func pendingActionDelete() throws {
|
|
let store = try makeStore()
|
|
try makeAccount(store)
|
|
|
|
try store.insertPendingAction(PendingActionRecord(
|
|
id: "pa1", accountId: "acc1",
|
|
actionType: "move", payload: "{}",
|
|
createdAt: "2026-03-14T10:00:00Z"
|
|
))
|
|
try store.deletePendingAction(id: "pa1")
|
|
|
|
let count = try store.pendingActionCount(accountId: "acc1")
|
|
#expect(count == 0)
|
|
}
|
|
|
|
@Test("pendingActions ordered by createdAt ASC")
|
|
func pendingActionsOrdering() throws {
|
|
let store = try makeStore()
|
|
try makeAccount(store)
|
|
|
|
try store.insertPendingActions([
|
|
PendingActionRecord(
|
|
id: "pa2", accountId: "acc1",
|
|
actionType: "move", payload: "{}",
|
|
createdAt: "2026-03-14T10:05:00Z"
|
|
),
|
|
PendingActionRecord(
|
|
id: "pa1", accountId: "acc1",
|
|
actionType: "send", payload: "{}",
|
|
createdAt: "2026-03-14T10:00:00Z"
|
|
),
|
|
])
|
|
|
|
let actions = try store.pendingActions(accountId: "acc1")
|
|
#expect(actions[0].id == "pa1")
|
|
#expect(actions[1].id == "pa2")
|
|
}
|
|
|
|
@Test("pendingActionCount returns correct count")
|
|
func pendingActionCount() throws {
|
|
let store = try makeStore()
|
|
try makeAccount(store)
|
|
|
|
try store.insertPendingActions([
|
|
PendingActionRecord(
|
|
id: "pa1", accountId: "acc1",
|
|
actionType: "send", payload: "{}",
|
|
createdAt: "2026-03-14T10:00:00Z"
|
|
),
|
|
PendingActionRecord(
|
|
id: "pa2", accountId: "acc1",
|
|
actionType: "move", payload: "{}",
|
|
createdAt: "2026-03-14T10:01:00Z"
|
|
),
|
|
])
|
|
|
|
let count = try store.pendingActionCount(accountId: "acc1")
|
|
#expect(count == 2)
|
|
}
|
|
|
|
// MARK: - Mailbox role queries
|
|
|
|
@Test("mailboxWithRole finds mailbox by role")
|
|
func mailboxWithRole() throws {
|
|
let store = try makeStore()
|
|
try makeAccount(store)
|
|
try store.upsertMailbox(MailboxRecord(
|
|
id: "mb1", accountId: "acc1", name: "Trash",
|
|
uidValidity: 1, uidNext: 1, role: "trash"
|
|
))
|
|
try store.upsertMailbox(MailboxRecord(
|
|
id: "mb2", accountId: "acc1", name: "INBOX",
|
|
uidValidity: 1, uidNext: 1
|
|
))
|
|
|
|
let trash = try store.mailboxWithRole("trash", accountId: "acc1")
|
|
#expect(trash?.id == "mb1")
|
|
|
|
let archive = try store.mailboxWithRole("archive", accountId: "acc1")
|
|
#expect(archive == nil)
|
|
}
|
|
|
|
@Test("updateMailboxRole sets role on mailbox")
|
|
func updateMailboxRole() throws {
|
|
let store = try makeStore()
|
|
try makeAccount(store)
|
|
try store.upsertMailbox(MailboxRecord(
|
|
id: "mb1", accountId: "acc1", name: "Archive",
|
|
uidValidity: 1, uidNext: 1
|
|
))
|
|
|
|
try store.updateMailboxRole(id: "mb1", role: "archive")
|
|
let mailbox = try store.mailbox(id: "mb1")
|
|
#expect(mailbox?.role == "archive")
|
|
|
|
try store.updateMailboxRole(id: "mb1", role: nil)
|
|
let cleared = try store.mailbox(id: "mb1")
|
|
#expect(cleared?.role == nil)
|
|
}
|
|
|
|
// MARK: - Message mutations
|
|
|
|
@Test("updateMessageMailbox moves message to new mailbox")
|
|
func updateMessageMailbox() throws {
|
|
let store = try makeStore()
|
|
try makeAccount(store)
|
|
try store.upsertMailbox(MailboxRecord(
|
|
id: "mb1", accountId: "acc1", name: "INBOX",
|
|
uidValidity: 1, uidNext: 100
|
|
))
|
|
try store.upsertMailbox(MailboxRecord(
|
|
id: "mb2", accountId: "acc1", name: "Trash",
|
|
uidValidity: 1, uidNext: 1, role: "trash"
|
|
))
|
|
try store.insertMessages([
|
|
MessageRecord(
|
|
id: "m1", accountId: "acc1", mailboxId: "mb1", uid: 1,
|
|
messageId: nil, inReplyTo: nil, refs: nil,
|
|
subject: "Test", fromAddress: nil, fromName: nil,
|
|
toAddresses: nil, ccAddresses: nil,
|
|
date: "2026-03-14T10:00:00Z", snippet: nil,
|
|
bodyText: nil, bodyHtml: nil,
|
|
isRead: false, isFlagged: false, size: 0
|
|
),
|
|
])
|
|
|
|
try store.updateMessageMailbox(messageId: "m1", newMailboxId: "mb2")
|
|
let msg = try store.message(id: "m1")
|
|
#expect(msg?.mailboxId == "mb2")
|
|
}
|
|
|
|
@Test("deleteMessage removes message")
|
|
func deleteMessage() throws {
|
|
let store = try makeStore()
|
|
try makeAccount(store)
|
|
try store.upsertMailbox(MailboxRecord(
|
|
id: "mb1", accountId: "acc1", name: "INBOX",
|
|
uidValidity: 1, uidNext: 100
|
|
))
|
|
try store.insertMessages([
|
|
MessageRecord(
|
|
id: "m1", accountId: "acc1", mailboxId: "mb1", uid: 1,
|
|
messageId: nil, inReplyTo: nil, refs: nil,
|
|
subject: "Test", fromAddress: nil, fromName: nil,
|
|
toAddresses: nil, ccAddresses: nil,
|
|
date: "2026-03-14T10:00:00Z", snippet: nil,
|
|
bodyText: nil, bodyHtml: nil,
|
|
isRead: false, isFlagged: false, size: 0
|
|
),
|
|
])
|
|
|
|
try store.deleteMessage(id: "m1")
|
|
let msg = try store.message(id: "m1")
|
|
#expect(msg == nil)
|
|
}
|
|
|
|
@Test("v4_attachment migration adds sectionPath to attachment and hasAttachments to message")
|
|
func v4AttachmentMigration() throws {
|
|
let db = try DatabaseSetup.openInMemoryDatabase()
|
|
try db.read { db in
|
|
let attachmentColumns = try db.columns(in: "attachment")
|
|
let attachmentColumnNames = attachmentColumns.map(\.name)
|
|
#expect(attachmentColumnNames.contains("sectionPath"))
|
|
|
|
let messageColumns = try db.columns(in: "message")
|
|
let messageColumnNames = messageColumns.map(\.name)
|
|
#expect(messageColumnNames.contains("hasAttachments"))
|
|
}
|
|
}
|
|
|
|
@Test("messagesInThread filters by mailbox")
|
|
func messagesInThreadByMailbox() throws {
|
|
let store = try makeStore()
|
|
try makeAccount(store)
|
|
try store.upsertMailbox(MailboxRecord(
|
|
id: "mb1", accountId: "acc1", name: "INBOX",
|
|
uidValidity: 1, uidNext: 100
|
|
))
|
|
try store.upsertMailbox(MailboxRecord(
|
|
id: "mb2", accountId: "acc1", name: "Sent",
|
|
uidValidity: 1, uidNext: 1, role: "sent"
|
|
))
|
|
try store.insertMessages([
|
|
MessageRecord(
|
|
id: "m1", accountId: "acc1", mailboxId: "mb1", uid: 1,
|
|
messageId: "msg1@example.com", inReplyTo: nil, refs: nil,
|
|
subject: "Hello", fromAddress: "alice@example.com", fromName: "Alice",
|
|
toAddresses: nil, ccAddresses: nil,
|
|
date: "2026-03-14T10:00:00Z", snippet: nil,
|
|
bodyText: nil, bodyHtml: nil,
|
|
isRead: false, isFlagged: false, size: 0
|
|
),
|
|
MessageRecord(
|
|
id: "m2", accountId: "acc1", mailboxId: "mb2", uid: 1,
|
|
messageId: "msg2@example.com", inReplyTo: "msg1@example.com", refs: nil,
|
|
subject: "Re: Hello", fromAddress: "user@example.com", fromName: "User",
|
|
toAddresses: nil, ccAddresses: nil,
|
|
date: "2026-03-14T10:01:00Z", snippet: nil,
|
|
bodyText: nil, bodyHtml: nil,
|
|
isRead: true, isFlagged: false, size: 0
|
|
),
|
|
])
|
|
try store.insertThread(ThreadRecord(
|
|
id: "t1", accountId: "acc1", subject: "Hello",
|
|
lastDate: "2026-03-14T10:01:00Z", messageCount: 2
|
|
))
|
|
try store.linkMessageToThread(threadId: "t1", messageId: "m1")
|
|
try store.linkMessageToThread(threadId: "t1", messageId: "m2")
|
|
|
|
let inboxMsgs = try store.messagesInThread(threadId: "t1", mailboxId: "mb1")
|
|
#expect(inboxMsgs.count == 1)
|
|
#expect(inboxMsgs[0].id == "m1")
|
|
|
|
let sentMsgs = try store.messagesInThread(threadId: "t1", mailboxId: "mb2")
|
|
#expect(sentMsgs.count == 1)
|
|
#expect(sentMsgs[0].id == "m2")
|
|
}
|
|
}
|