Files

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