add v0.4 models, schema, query extensions for GTD tasks, labels, deferrals

New model types: TaskStatus, TaskSummary, LabelInfo, ItemSummary.
GRDB records: TaskRecord, LabelRecord, ItemLabelRecord, DeferralRecord.
Database migrations v3_task, v3_label, v3_deferral with indexes.
MailStore query extensions for labels, item-labels, deferrals, task cache.
TaskStore module wrapping MailStore. Tests for all v3 tables and records.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-14 08:51:15 +01:00
parent 18e7ff2c47
commit a4f0761f25
14 changed files with 925 additions and 1 deletions

View File

@@ -11,6 +11,7 @@ let package = Package(
products: [
.library(name: "Models", targets: ["Models"]),
.library(name: "MailStore", targets: ["MailStore"]),
.library(name: "TaskStore", targets: ["TaskStore"]),
.library(name: "IMAPClient", targets: ["IMAPClient"]),
.library(name: "SMTPClient", targets: ["SMTPClient"]),
.library(name: "SyncEngine", targets: ["SyncEngine"]),
@@ -46,9 +47,17 @@ let package = Package(
.product(name: "NIOSSL", package: "swift-nio-ssl"),
]
),
.target(
name: "TaskStore",
dependencies: [
"Models",
"MailStore",
.product(name: "GRDB", package: "GRDB.swift"),
]
),
.target(
name: "SyncEngine",
dependencies: ["Models", "IMAPClient", "SMTPClient", "MailStore"]
dependencies: ["Models", "IMAPClient", "SMTPClient", "MailStore", "TaskStore"]
),
.testTarget(name: "ModelsTests", dependencies: ["Models"]),
.testTarget(name: "MailStoreTests", dependencies: ["MailStore"]),
@@ -59,6 +68,7 @@ let package = Package(
]
),
.testTarget(name: "SMTPClientTests", dependencies: ["SMTPClient", "Models"]),
.testTarget(name: "TaskStoreTests", dependencies: ["TaskStore", "MailStore"]),
.testTarget(name: "SyncEngineTests", dependencies: ["SyncEngine", "IMAPClient", "MailStore"]),
]
)

View File

@@ -127,6 +127,65 @@ public enum DatabaseSetup {
try db.create(index: "idx_pendingAction_createdAt", on: "pendingAction", columns: ["createdAt"])
}
migrator.registerMigration("v3_task") { db in
try db.create(table: "task") { t in
t.primaryKey("id", .text)
t.belongsTo("account", onDelete: .cascade).notNull()
t.column("summary", .text).notNull()
t.column("description", .text)
t.column("status", .text).notNull().defaults(to: "NEEDS-ACTION")
t.column("priority", .integer).notNull().defaults(to: 0)
t.column("dueDate", .text)
t.column("deferUntil", .text)
t.column("createdAt", .text).notNull()
t.column("updatedAt", .text).notNull()
t.column("filePath", .text)
t.column("linkedMessageId", .text)
t.column("isSomeday", .boolean).notNull().defaults(to: false)
}
try db.create(index: "idx_task_status", on: "task", columns: ["status"])
try db.create(index: "idx_task_deferUntil", on: "task", columns: ["deferUntil"])
try db.create(index: "idx_task_dueDate", on: "task", columns: ["dueDate"])
try db.create(index: "idx_task_linkedMessageId", on: "task", columns: ["linkedMessageId"])
}
migrator.registerMigration("v3_label") { db in
try db.create(table: "label") { t in
t.primaryKey("id", .text)
t.belongsTo("account", onDelete: .cascade).notNull()
t.column("name", .text).notNull()
t.column("isProject", .boolean).notNull().defaults(to: false)
t.column("color", .text)
}
try db.create(
index: "idx_label_accountId_name",
on: "label",
columns: ["accountId", "name"],
unique: true
)
try db.create(table: "itemLabel") { t in
t.column("labelId", .text).notNull()
.references("label", onDelete: .cascade)
t.column("itemType", .text).notNull()
t.column("itemId", .text).notNull()
t.primaryKey(["labelId", "itemType", "itemId"])
}
}
migrator.registerMigration("v3_deferral") { db in
try db.create(table: "deferral") { t in
t.primaryKey("id", .text)
t.column("messageId", .text).notNull()
.references("message", column: "id", onDelete: .cascade)
t.column("deferUntil", .text)
t.column("originalMailbox", .text)
t.column("createdAt", .text).notNull()
t.uniqueKey(["messageId"])
}
try db.create(index: "idx_deferral_deferUntil", on: "deferral", columns: ["deferUntil"])
}
return migrator
}

View File

@@ -316,4 +316,265 @@ public final class MailStore: Sendable {
public var databaseReader: any DatabaseReader {
dbWriter
}
/// Access the underlying database writer (for TaskStore and other modules)
public var databaseWriter: any DatabaseWriter {
dbWriter
}
// MARK: - Labels
public func insertLabel(_ label: LabelRecord) throws {
try dbWriter.write { db in
try label.insert(db)
}
}
public func labels(accountId: String) throws -> [LabelRecord] {
try dbWriter.read { db in
try LabelRecord
.filter(Column("accountId") == accountId)
.order(Column("name"))
.fetchAll(db)
}
}
public func projectLabels(accountId: String) throws -> [LabelRecord] {
try dbWriter.read { db in
try LabelRecord
.filter(Column("accountId") == accountId)
.filter(Column("isProject") == true)
.order(Column("name"))
.fetchAll(db)
}
}
public func deleteLabel(id: String) throws {
try dbWriter.write { db in
_ = try LabelRecord.deleteOne(db, key: id)
}
}
public func labelByName(_ name: String, accountId: String) throws -> LabelRecord? {
try dbWriter.read { db in
try LabelRecord
.filter(Column("accountId") == accountId)
.filter(Column("name") == name)
.fetchOne(db)
}
}
// MARK: - Item Labels
public func attachLabel(labelId: String, itemType: String, itemId: String) throws {
try dbWriter.write { db in
try ItemLabelRecord(labelId: labelId, itemType: itemType, itemId: itemId).save(db)
}
}
public func detachLabel(labelId: String, itemType: String, itemId: String) throws {
try dbWriter.write { db in
try db.execute(
sql: "DELETE FROM itemLabel WHERE labelId = ? AND itemType = ? AND itemId = ?",
arguments: [labelId, itemType, itemId]
)
}
}
public func labelsForItem(itemType: String, itemId: String) throws -> [LabelRecord] {
try dbWriter.read { db in
try LabelRecord.fetchAll(db, sql: """
SELECT l.* FROM label l
JOIN itemLabel il ON il.labelId = l.id
WHERE il.itemType = ? AND il.itemId = ?
ORDER BY l.name
""", arguments: [itemType, itemId])
}
}
public func itemsWithLabel(labelId: String) throws -> [ItemLabelRecord] {
try dbWriter.read { db in
try ItemLabelRecord
.filter(Column("labelId") == labelId)
.fetchAll(db)
}
}
// MARK: - Deferrals (email only)
public func insertDeferral(_ deferral: DeferralRecord) throws {
try dbWriter.write { db in
try deferral.insert(db)
}
}
public func deleteDeferral(id: String) throws {
try dbWriter.write { db in
_ = try DeferralRecord.deleteOne(db, key: id)
}
}
public func deleteDeferralForMessage(messageId: String) throws {
try dbWriter.write { db in
try db.execute(
sql: "DELETE FROM deferral WHERE messageId = ?",
arguments: [messageId]
)
}
}
public func deferralForMessage(messageId: String) throws -> DeferralRecord? {
try dbWriter.read { db in
try DeferralRecord
.filter(Column("messageId") == messageId)
.fetchOne(db)
}
}
public func expiredDeferrals(beforeDate: String) throws -> [DeferralRecord] {
try dbWriter.read { db in
try DeferralRecord
.filter(Column("deferUntil") != nil)
.filter(Column("deferUntil") <= beforeDate)
.fetchAll(db)
}
}
public func somedayDeferrals() throws -> [DeferralRecord] {
try dbWriter.read { db in
try DeferralRecord
.filter(Column("deferUntil") == nil)
.fetchAll(db)
}
}
// MARK: - Task cache queries
public func insertTask(_ task: TaskRecord) throws {
try dbWriter.write { db in
try task.insert(db)
}
}
public func updateTask(_ task: TaskRecord) throws {
try dbWriter.write { db in
try task.update(db)
}
}
public func deleteTask(id: String) throws {
try dbWriter.write { db in
_ = try TaskRecord.deleteOne(db, key: id)
}
}
public func task(id: String) throws -> TaskRecord? {
try dbWriter.read { db in
try TaskRecord.fetchOne(db, key: id)
}
}
public func inboxTasks(accountId: String) throws -> [TaskRecord] {
try dbWriter.read { db in
try TaskRecord
.filter(Column("accountId") == accountId)
.filter(Column("status") == "NEEDS-ACTION")
.filter(Column("deferUntil") == nil)
.filter(Column("isSomeday") == false)
.order(Column("createdAt").desc)
.fetchAll(db)
}
}
public func todayTasks(accountId: String, todayDate: String) throws -> [TaskRecord] {
try dbWriter.read { db in
try TaskRecord
.filter(Column("accountId") == accountId)
.filter(Column("status") == "NEEDS-ACTION" || Column("status") == "IN-PROCESS")
.filter(Column("dueDate") != nil)
.filter(Column("dueDate") <= todayDate)
.filter(Column("isSomeday") == false)
.order(Column("priority").desc, Column("dueDate").asc)
.fetchAll(db)
}
}
public func upcomingTasks(accountId: String, afterDate: String) throws -> [TaskRecord] {
try dbWriter.read { db in
try TaskRecord
.filter(Column("accountId") == accountId)
.filter(Column("status") == "NEEDS-ACTION" || Column("status") == "IN-PROCESS")
.filter(Column("dueDate") != nil)
.filter(Column("dueDate") > afterDate)
.filter(Column("isSomeday") == false)
.order(Column("dueDate").asc)
.fetchAll(db)
}
}
public func somedayTasks(accountId: String) throws -> [TaskRecord] {
try dbWriter.read { db in
try TaskRecord
.filter(Column("accountId") == accountId)
.filter(Column("isSomeday") == true)
.filter(Column("status") == "NEEDS-ACTION")
.order(Column("createdAt").desc)
.fetchAll(db)
}
}
public func archivedTasks(accountId: String) throws -> [TaskRecord] {
try dbWriter.read { db in
try TaskRecord
.filter(Column("accountId") == accountId)
.filter(Column("status") == "COMPLETED" || Column("status") == "CANCELLED")
.order(Column("updatedAt").desc)
.fetchAll(db)
}
}
public func tasksLinkedToThread(threadId: String) throws -> [TaskRecord] {
try dbWriter.read { db in
try TaskRecord.fetchAll(db, sql: """
SELECT t.* FROM task t
WHERE t.linkedMessageId IN (
SELECT m.messageId FROM message m
JOIN threadMessage tm ON tm.messageId = m.id
WHERE tm.threadId = ?
)
ORDER BY t.createdAt ASC
""", arguments: [threadId])
}
}
public func expiredDeferredTasks(beforeDate: String) throws -> [TaskRecord] {
try dbWriter.read { db in
try TaskRecord
.filter(Column("deferUntil") != nil)
.filter(Column("deferUntil") <= beforeDate)
.filter(Column("status") == "NEEDS-ACTION")
.fetchAll(db)
}
}
public func clearTaskDeferral(id: String) throws {
try dbWriter.write { db in
try db.execute(
sql: "UPDATE task SET deferUntil = NULL WHERE id = ?",
arguments: [id]
)
}
}
public func deleteAllTasks() throws {
try dbWriter.write { db in
_ = try TaskRecord.deleteAll(db)
}
}
public func deleteAllItemLabelsForTasks() throws {
try dbWriter.write { db in
try db.execute(sql: "DELETE FROM itemLabel WHERE itemType = 'task'")
}
}
}

View File

@@ -0,0 +1,25 @@
import GRDB
public struct DeferralRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
public static let databaseTableName = "deferral"
public var id: String
public var messageId: String
public var deferUntil: String?
public var originalMailbox: String?
public var createdAt: String
public init(
id: String,
messageId: String,
deferUntil: String? = nil,
originalMailbox: String? = nil,
createdAt: String
) {
self.id = id
self.messageId = messageId
self.deferUntil = deferUntil
self.originalMailbox = originalMailbox
self.createdAt = createdAt
}
}

View File

@@ -0,0 +1,15 @@
import GRDB
public struct ItemLabelRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
public static let databaseTableName = "itemLabel"
public var labelId: String
public var itemType: String
public var itemId: String
public init(labelId: String, itemType: String, itemId: String) {
self.labelId = labelId
self.itemType = itemType
self.itemId = itemId
}
}

View File

@@ -0,0 +1,25 @@
import GRDB
public struct LabelRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
public static let databaseTableName = "label"
public var id: String
public var accountId: String
public var name: String
public var isProject: Bool
public var color: String?
public init(
id: String,
accountId: String,
name: String,
isProject: Bool = false,
color: String? = nil
) {
self.id = id
self.accountId = accountId
self.name = name
self.isProject = isProject
self.color = color
}
}

View File

@@ -0,0 +1,50 @@
import Foundation
import GRDB
public struct TaskRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
public static let databaseTableName = "task"
public var id: String
public var accountId: String
public var summary: String
public var description: String?
public var status: String
public var priority: Int
public var dueDate: String?
public var deferUntil: String?
public var createdAt: String
public var updatedAt: String
public var filePath: String?
public var linkedMessageId: String?
public var isSomeday: Bool
public init(
id: String,
accountId: String,
summary: String,
description: String? = nil,
status: String = "NEEDS-ACTION",
priority: Int = 0,
dueDate: String? = nil,
deferUntil: String? = nil,
createdAt: String,
updatedAt: String,
filePath: String? = nil,
linkedMessageId: String? = nil,
isSomeday: Bool = false
) {
self.id = id
self.accountId = accountId
self.summary = summary
self.description = description
self.status = status
self.priority = priority
self.dueDate = dueDate
self.deferUntil = deferUntil
self.createdAt = createdAt
self.updatedAt = updatedAt
self.filePath = filePath
self.linkedMessageId = linkedMessageId
self.isSomeday = isSomeday
}
}

View File

@@ -0,0 +1,48 @@
import Foundation
public enum ItemSummary: Sendable, Identifiable, Equatable {
case email(MessageSummary)
case task(TaskSummary)
public var id: String {
switch self {
case .email(let msg): "email-\(msg.id)"
case .task(let task): "task-\(task.id)"
}
}
public var title: String {
switch self {
case .email(let msg): msg.subject ?? "(no subject)"
case .task(let task): task.summary
}
}
public var date: Date {
switch self {
case .email(let msg): msg.date
case .task(let task): task.createdAt
}
}
public var dueDate: Date? {
switch self {
case .email: nil
case .task(let task): task.dueDate
}
}
public var isDeferred: Bool {
switch self {
case .email: false
case .task(let task): task.deferUntil != nil
}
}
public var isSomeday: Bool {
switch self {
case .email: false
case .task(let task): task.isSomeday
}
}
}

View File

@@ -0,0 +1,13 @@
public struct LabelInfo: Sendable, Identifiable, Equatable {
public var id: String
public var name: String
public var isProject: Bool
public var color: String?
public init(id: String, name: String, isProject: Bool = false, color: String? = nil) {
self.id = id
self.name = name
self.isProject = isProject
self.color = color
}
}

View File

@@ -0,0 +1,6 @@
public enum TaskStatus: String, Sendable, Codable {
case needsAction = "NEEDS-ACTION"
case inProcess = "IN-PROCESS"
case completed = "COMPLETED"
case cancelled = "CANCELLED"
}

View File

@@ -0,0 +1,36 @@
import Foundation
public struct TaskSummary: Sendable, Identifiable, Equatable {
public var id: String
public var accountId: String
public var summary: String
public var description: String?
public var status: TaskStatus
public var priority: Int
public var dueDate: Date?
public var deferUntil: Date?
public var createdAt: Date
public var linkedMessageId: String?
public var categories: [String]
public var isSomeday: Bool
public init(
id: String, accountId: String, summary: String, description: String? = nil,
status: TaskStatus = .needsAction, priority: Int = 0, dueDate: Date? = nil,
deferUntil: Date? = nil, createdAt: Date = Date(), linkedMessageId: String? = nil,
categories: [String] = [], isSomeday: Bool = false
) {
self.id = id
self.accountId = accountId
self.summary = summary
self.description = description
self.status = status
self.priority = priority
self.dueDate = dueDate
self.deferUntil = deferUntil
self.createdAt = createdAt
self.linkedMessageId = linkedMessageId
self.categories = categories
self.isSomeday = isSomeday
}
}

View File

@@ -0,0 +1,13 @@
@_exported import MailStore
import Models
/// TaskStore provides task-specific business logic on top of MailStore's
/// database records and queries. The underlying TaskRecord and its CRUD
/// operations live in MailStore to share the same database connection.
public struct TaskStore: Sendable {
public let mailStore: MailStore
public init(mailStore: MailStore) {
self.mailStore = mailStore
}
}

View File

@@ -0,0 +1,350 @@
import Testing
import GRDB
@testable import MailStore
@Suite("V3 Labels, Deferrals & Tasks")
struct LabelDeferralTests {
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
))
}
func makeMailboxAndMessage(_ store: MailStore) throws {
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: "msg1@example.com", inReplyTo: nil, refs: nil,
subject: "Test", 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: 100
),
])
}
// MARK: - Schema verification
@Test("v3 tables exist after migration")
func v3TablesExist() throws {
let store = try makeStore()
let tables = try store.databaseWriter.read { db in
try String.fetchAll(db, sql: """
SELECT name FROM sqlite_master WHERE type='table'
AND name IN ('task', 'label', 'itemLabel', 'deferral')
ORDER BY name
""")
}
#expect(tables == ["deferral", "itemLabel", "label", "task"])
}
@Test("task table has expected columns")
func taskColumns() throws {
let store = try makeStore()
let columns = try store.databaseWriter.read { db in
try db.columns(in: "task").map(\.name)
}
let expected = [
"id", "accountId", "summary", "description", "status",
"priority", "dueDate", "deferUntil", "createdAt", "updatedAt",
"filePath", "linkedMessageId", "isSomeday",
]
for col in expected {
#expect(columns.contains(col), "missing column: \(col)")
}
}
// MARK: - LabelRecord round-trip
@Test("LabelRecord insert and fetch round-trip")
func labelRoundTrip() throws {
let store = try makeStore()
try makeAccount(store)
let label = LabelRecord(
id: "lbl1", accountId: "acc1", name: "urgent",
isProject: false, color: "#ff0000"
)
try store.insertLabel(label)
let labels = try store.labels(accountId: "acc1")
#expect(labels.count == 1)
#expect(labels[0].name == "urgent")
#expect(labels[0].color == "#ff0000")
#expect(labels[0].isProject == false)
}
@Test("labelByName finds label by account and name")
func labelByName() throws {
let store = try makeStore()
try makeAccount(store)
try store.insertLabel(LabelRecord(
id: "lbl1", accountId: "acc1", name: "work"
))
let found = try store.labelByName("work", accountId: "acc1")
#expect(found?.id == "lbl1")
let notFound = try store.labelByName("personal", accountId: "acc1")
#expect(notFound == nil)
}
@Test("projectLabels returns only project labels")
func projectLabels() throws {
let store = try makeStore()
try makeAccount(store)
try store.insertLabel(LabelRecord(
id: "lbl1", accountId: "acc1", name: "context", isProject: false
))
try store.insertLabel(LabelRecord(
id: "lbl2", accountId: "acc1", name: "my-project", isProject: true
))
let projects = try store.projectLabels(accountId: "acc1")
#expect(projects.count == 1)
#expect(projects[0].name == "my-project")
}
@Test("deleteLabel removes label and cascades to itemLabel")
func deleteLabelCascade() throws {
let store = try makeStore()
try makeAccount(store)
try store.insertLabel(LabelRecord(
id: "lbl1", accountId: "acc1", name: "test"
))
try store.attachLabel(labelId: "lbl1", itemType: "email", itemId: "m1")
try store.deleteLabel(id: "lbl1")
let labels = try store.labels(accountId: "acc1")
#expect(labels.isEmpty)
let items = try store.itemsWithLabel(labelId: "lbl1")
#expect(items.isEmpty)
}
// MARK: - ItemLabelRecord round-trip
@Test("ItemLabelRecord attach and fetch round-trip")
func itemLabelRoundTrip() throws {
let store = try makeStore()
try makeAccount(store)
try store.insertLabel(LabelRecord(
id: "lbl1", accountId: "acc1", name: "important"
))
try store.attachLabel(labelId: "lbl1", itemType: "email", itemId: "m1")
let labels = try store.labelsForItem(itemType: "email", itemId: "m1")
#expect(labels.count == 1)
#expect(labels[0].id == "lbl1")
let items = try store.itemsWithLabel(labelId: "lbl1")
#expect(items.count == 1)
#expect(items[0].itemId == "m1")
}
@Test("detachLabel removes item-label link")
func detachLabel() throws {
let store = try makeStore()
try makeAccount(store)
try store.insertLabel(LabelRecord(
id: "lbl1", accountId: "acc1", name: "test"
))
try store.attachLabel(labelId: "lbl1", itemType: "email", itemId: "m1")
try store.detachLabel(labelId: "lbl1", itemType: "email", itemId: "m1")
let labels = try store.labelsForItem(itemType: "email", itemId: "m1")
#expect(labels.isEmpty)
}
// MARK: - DeferralRecord round-trip
@Test("DeferralRecord insert and fetch round-trip")
func deferralRoundTrip() throws {
let store = try makeStore()
try makeAccount(store)
try makeMailboxAndMessage(store)
let deferral = DeferralRecord(
id: "def1", messageId: "m1",
deferUntil: "2026-03-15T09:00:00Z",
originalMailbox: "mb1",
createdAt: "2026-03-14T10:00:00Z"
)
try store.insertDeferral(deferral)
let fetched = try store.deferralForMessage(messageId: "m1")
#expect(fetched != nil)
#expect(fetched?.deferUntil == "2026-03-15T09:00:00Z")
#expect(fetched?.originalMailbox == "mb1")
}
@Test("deferral cascade on message delete")
func deferralCascadeOnMessageDelete() throws {
let store = try makeStore()
try makeAccount(store)
try makeMailboxAndMessage(store)
try store.insertDeferral(DeferralRecord(
id: "def1", messageId: "m1",
deferUntil: "2026-03-15T09:00:00Z",
createdAt: "2026-03-14T10:00:00Z"
))
try store.deleteMessage(id: "m1")
let fetched = try store.deferralForMessage(messageId: "m1")
#expect(fetched == nil)
}
@Test("expiredDeferrals returns deferrals before given date")
func expiredDeferrals() throws {
let store = try makeStore()
try makeAccount(store)
try makeMailboxAndMessage(store)
// Add second message for second deferral
try store.insertMessages([
MessageRecord(
id: "m2", accountId: "acc1", mailboxId: "mb1", uid: 2,
messageId: "msg2@example.com", inReplyTo: nil, refs: nil,
subject: "Future", 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.insertDeferral(DeferralRecord(
id: "def1", messageId: "m1",
deferUntil: "2026-03-14T08:00:00Z",
createdAt: "2026-03-13T10:00:00Z"
))
try store.insertDeferral(DeferralRecord(
id: "def2", messageId: "m2",
deferUntil: "2026-03-16T08:00:00Z",
createdAt: "2026-03-13T10:00:00Z"
))
let expired = try store.expiredDeferrals(beforeDate: "2026-03-15T00:00:00Z")
#expect(expired.count == 1)
#expect(expired[0].id == "def1")
}
@Test("somedayDeferrals returns deferrals with nil deferUntil")
func somedayDeferrals() throws {
let store = try makeStore()
try makeAccount(store)
try makeMailboxAndMessage(store)
try store.insertDeferral(DeferralRecord(
id: "def1", messageId: "m1",
deferUntil: nil,
createdAt: "2026-03-14T10:00:00Z"
))
let someday = try store.somedayDeferrals()
#expect(someday.count == 1)
#expect(someday[0].id == "def1")
}
// MARK: - TaskRecord round-trip
@Test("TaskRecord insert and fetch round-trip")
func taskRoundTrip() throws {
let store = try makeStore()
try makeAccount(store)
let task = TaskRecord(
id: "t1", accountId: "acc1", summary: "Buy groceries",
description: "Milk, eggs, bread",
status: "NEEDS-ACTION", priority: 5,
dueDate: "2026-03-15",
createdAt: "2026-03-14T10:00:00Z",
updatedAt: "2026-03-14T10:00:00Z",
linkedMessageId: "msg1@example.com"
)
try store.insertTask(task)
let fetched = try store.task(id: "t1")
#expect(fetched != nil)
#expect(fetched?.summary == "Buy groceries")
#expect(fetched?.description == "Milk, eggs, bread")
#expect(fetched?.priority == 5)
#expect(fetched?.linkedMessageId == "msg1@example.com")
}
@Test("inboxTasks returns only active non-deferred non-someday tasks")
func inboxTasks() throws {
let store = try makeStore()
try makeAccount(store)
// Inbox task
try store.insertTask(TaskRecord(
id: "t1", accountId: "acc1", summary: "Inbox task",
createdAt: "2026-03-14T10:00:00Z",
updatedAt: "2026-03-14T10:00:00Z"
))
// Deferred task
try store.insertTask(TaskRecord(
id: "t2", accountId: "acc1", summary: "Deferred",
deferUntil: "2026-03-20T10:00:00Z",
createdAt: "2026-03-14T10:00:00Z",
updatedAt: "2026-03-14T10:00:00Z"
))
// Someday task
try store.insertTask(TaskRecord(
id: "t3", accountId: "acc1", summary: "Someday",
createdAt: "2026-03-14T10:00:00Z",
updatedAt: "2026-03-14T10:00:00Z",
isSomeday: true
))
// Completed task
try store.insertTask(TaskRecord(
id: "t4", accountId: "acc1", summary: "Done",
status: "COMPLETED",
createdAt: "2026-03-14T10:00:00Z",
updatedAt: "2026-03-14T10:00:00Z"
))
let inbox = try store.inboxTasks(accountId: "acc1")
#expect(inbox.count == 1)
#expect(inbox[0].id == "t1")
}
@Test("deleteAllTasks removes all tasks")
func deleteAllTasks() throws {
let store = try makeStore()
try makeAccount(store)
try store.insertTask(TaskRecord(
id: "t1", accountId: "acc1", summary: "Task 1",
createdAt: "2026-03-14T10:00:00Z",
updatedAt: "2026-03-14T10:00:00Z"
))
try store.insertTask(TaskRecord(
id: "t2", accountId: "acc1", summary: "Task 2",
createdAt: "2026-03-14T10:00:00Z",
updatedAt: "2026-03-14T10:00:00Z"
))
try store.deleteAllTasks()
let remaining = try store.task(id: "t1")
#expect(remaining == nil)
}
}

View File

@@ -0,0 +1,13 @@
import Testing
@testable import TaskStore
@testable import MailStore
@Suite("TaskStore")
struct TaskStoreTests {
@Test("TaskStore wraps MailStore")
func taskStoreInit() throws {
let mailStore = try MailStore(dbWriter: DatabaseSetup.openInMemoryDatabase())
let taskStore = TaskStore(mailStore: mailStore)
#expect(taskStore.mailStore === mailStore)
}
}