Conversation text is still only persisted when explicitly saved (⌘S), but tokens/cost/model/provider are now logged to a new usage_events table for every completed AI response regardless — the Analytics view now reads from this table instead of messages, so it reflects real usage even for conversations that were never saved. Adds a By Provider chart mode alongside Over Time/By Model. Also fixes the Analytics entry point: ToolbarItem(placement: .navigation) silently doesn't render in a plain .sheet-presented NavigationStack on macOS. Moved the button inline next to the segmented picker, matching the codebase's existing convention (e.g. the model-favorites star filter).
711 lines
31 KiB
Swift
711 lines
31 KiB
Swift
//
|
|
// DatabaseServiceTests.swift
|
|
// oAITests
|
|
//
|
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
// Copyright (C) 2026 Rune Olsen
|
|
|
|
import Testing
|
|
import Foundation
|
|
@testable import Confab
|
|
|
|
@Suite("DatabaseService migrations, against a throwaway in-memory queue")
|
|
struct DatabaseServiceMigrationTests {
|
|
|
|
@Test("All v1-v8 tables exist after migration")
|
|
func allTablesExist() {
|
|
let db = DatabaseService.makeInMemory()
|
|
let expectedTables = [
|
|
"conversations", "messages", "settings", "command_history",
|
|
"email_logs", "message_metadata", "message_embeddings",
|
|
"conversation_embeddings", "conversation_summaries",
|
|
]
|
|
for table in expectedTables {
|
|
#expect(db.tableExists(table), "expected table \(table) to exist")
|
|
}
|
|
}
|
|
|
|
@Test("v13 adds the usage_events table with the expected columns")
|
|
func v13AddsUsageEventsTable() {
|
|
let db = DatabaseService.makeInMemory()
|
|
#expect(db.tableExists("usage_events"))
|
|
let columns = Set(db.columnNames(in: "usage_events"))
|
|
let expected: Set<String> = [
|
|
"id", "timestamp", "provider", "modelId", "promptTokens", "completionTokens", "cost", "conversationId",
|
|
]
|
|
#expect(expected.isSubset(of: columns))
|
|
}
|
|
|
|
@Test("v4 adds modelId to messages and primaryModel to conversations")
|
|
func v4AddsModelColumns() {
|
|
let db = DatabaseService.makeInMemory()
|
|
#expect(db.columnNames(in: "messages").contains("modelId"))
|
|
#expect(db.columnNames(in: "conversations").contains("primaryModel"))
|
|
}
|
|
|
|
@Test("messages table has the expected v1 columns")
|
|
func messagesTableColumns() {
|
|
let db = DatabaseService.makeInMemory()
|
|
let columns = Set(db.columnNames(in: "messages"))
|
|
let expected: Set<String> = ["id", "conversationId", "role", "content", "tokens", "cost", "timestamp", "sortOrder"]
|
|
#expect(expected.isSubset(of: columns))
|
|
}
|
|
|
|
@Test("message_metadata table has the expected v6 columns")
|
|
func messageMetadataColumns() {
|
|
let db = DatabaseService.makeInMemory()
|
|
let columns = Set(db.columnNames(in: "message_metadata"))
|
|
#expect(columns == ["message_id", "importance_score", "user_starred", "summary", "chunk_index"])
|
|
}
|
|
|
|
@Test("conversation_summaries table has the expected v8 columns")
|
|
func conversationSummariesColumns() {
|
|
let db = DatabaseService.makeInMemory()
|
|
let columns = Set(db.columnNames(in: "conversation_summaries"))
|
|
#expect(columns == ["id", "conversation_id", "start_message_index", "end_message_index", "summary", "token_count", "created_at", "summary_model"])
|
|
}
|
|
|
|
@Test("A nonexistent table reports as absent, not a crash")
|
|
func nonexistentTableReportsAbsent() {
|
|
let db = DatabaseService.makeInMemory()
|
|
#expect(db.tableExists("not_a_real_table") == false)
|
|
}
|
|
|
|
@Test("Two in-memory instances are isolated from each other")
|
|
func instancesAreIsolated() throws {
|
|
let dbA = DatabaseService.makeInMemory()
|
|
let dbB = DatabaseService.makeInMemory()
|
|
|
|
_ = try dbA.saveConversation(name: "only in A", messages: [Message(role: .user, content: "hi")])
|
|
|
|
#expect(try dbA.listConversations().count == 1)
|
|
#expect(try dbB.listConversations().count == 0)
|
|
}
|
|
}
|
|
|
|
@Suite("DatabaseService settings CRUD, against a throwaway in-memory queue")
|
|
struct DatabaseServiceSettingsTests {
|
|
|
|
@Test("Round-trips a plain setting")
|
|
func roundTripsSetting() {
|
|
let db = DatabaseService.makeInMemory()
|
|
db.setSetting(key: "theme", value: "dark")
|
|
#expect((try? db.loadAllSettings()["theme"]) == "dark")
|
|
}
|
|
|
|
@Test("Deletes a setting")
|
|
func deletesSetting() {
|
|
let db = DatabaseService.makeInMemory()
|
|
db.setSetting(key: "temp", value: "1")
|
|
db.deleteSetting(key: "temp")
|
|
#expect((try? db.loadAllSettings()["temp"]) == nil)
|
|
}
|
|
}
|
|
|
|
@Suite("DatabaseService conversation + message persistence, against a throwaway in-memory queue")
|
|
struct DatabaseServiceConversationTests {
|
|
|
|
@Test("Saving a conversation round-trips its messages via loadConversation")
|
|
func savesAndLoadsConversation() throws {
|
|
let db = DatabaseService.makeInMemory()
|
|
let messages = [
|
|
Message(role: .user, content: "hello"),
|
|
Message(role: .assistant, content: "hi there"),
|
|
]
|
|
let saved = try db.saveConversation(name: "Test Chat", messages: messages)
|
|
|
|
let loaded = try db.loadConversation(id: saved.id)
|
|
#expect(loaded?.0.name == "Test Chat")
|
|
#expect(loaded?.1.map(\.content) == ["hello", "hi there"])
|
|
}
|
|
|
|
@Test("System messages are excluded from persistence")
|
|
func systemMessagesExcluded() throws {
|
|
let db = DatabaseService.makeInMemory()
|
|
let messages = [
|
|
Message(role: .system, content: "tool call"),
|
|
Message(role: .user, content: "hello"),
|
|
]
|
|
let saved = try db.saveConversation(name: "Test", messages: messages)
|
|
let loaded = try db.loadConversation(id: saved.id)
|
|
#expect(loaded?.1.count == 1)
|
|
#expect(loaded?.1.first?.content == "hello")
|
|
}
|
|
}
|
|
|
|
@Suite("DatabaseService usage statistics, against a throwaway in-memory queue")
|
|
struct DatabaseServiceUsageStatsTests {
|
|
|
|
@Test("Overall stats aggregate tokens, cost, and message count across conversations")
|
|
func overallStatsAggregate() throws {
|
|
let db = DatabaseService.makeInMemory()
|
|
_ = try db.saveConversation(name: "Chat A", messages: [
|
|
Message(role: .user, content: "hi", tokens: 10, modelId: "claude-sonnet"),
|
|
Message(role: .assistant, content: "hello", tokens: 20, cost: 0.01, modelId: "claude-sonnet"),
|
|
])
|
|
_ = try db.saveConversation(name: "Chat B", messages: [
|
|
Message(role: .user, content: "hey", tokens: 5, modelId: "gpt-4"),
|
|
Message(role: .assistant, content: "hi", tokens: 15, cost: 0.02, modelId: "gpt-4"),
|
|
])
|
|
|
|
let stats = try db.getOverallUsageStats()
|
|
#expect(stats.totalMessages == 4)
|
|
#expect(stats.totalTokens == 50)
|
|
#expect(stats.hasCostData == true)
|
|
#expect(abs(stats.totalCost - 0.03) < 0.0001)
|
|
}
|
|
|
|
@Test("Overall stats report no cost data when no message has a cost")
|
|
func overallStatsNoCostData() throws {
|
|
let db = DatabaseService.makeInMemory()
|
|
_ = try db.saveConversation(name: "Chat", messages: [
|
|
Message(role: .user, content: "hi", tokens: 10),
|
|
])
|
|
|
|
let stats = try db.getOverallUsageStats()
|
|
#expect(stats.hasCostData == false)
|
|
#expect(stats.totalCost == 0)
|
|
}
|
|
|
|
@Test("Usage by model groups messages by modelId and sums their tokens/cost")
|
|
func usageByModelGroups() throws {
|
|
let db = DatabaseService.makeInMemory()
|
|
_ = try db.saveConversation(name: "Chat A", messages: [
|
|
Message(role: .assistant, content: "a1", tokens: 20, cost: 0.01, modelId: "claude-sonnet"),
|
|
])
|
|
_ = try db.saveConversation(name: "Chat B", messages: [
|
|
Message(role: .assistant, content: "b1", tokens: 15, cost: 0.02, modelId: "gpt-4"),
|
|
Message(role: .assistant, content: "b2", tokens: 5, cost: 0.02, modelId: "gpt-4"),
|
|
])
|
|
|
|
let byModel = try db.getUsageByModel()
|
|
#expect(byModel.count == 2)
|
|
|
|
let gpt4 = byModel.first { $0.modelId == "gpt-4" }
|
|
#expect(gpt4?.messageCount == 2)
|
|
#expect(gpt4?.totalTokens == 20)
|
|
#expect(abs((gpt4?.totalCost ?? 0) - 0.04) < 0.0001)
|
|
|
|
// gpt-4 has higher total cost than claude-sonnet, so it should sort first
|
|
#expect(byModel.first?.modelId == "gpt-4")
|
|
}
|
|
|
|
@Test("Usage by model excludes messages with no modelId")
|
|
func usageByModelExcludesNilModelId() throws {
|
|
let db = DatabaseService.makeInMemory()
|
|
_ = try db.saveConversation(name: "Chat", messages: [
|
|
Message(role: .user, content: "hi", tokens: 10),
|
|
Message(role: .assistant, content: "hello", tokens: 20, modelId: "claude-sonnet"),
|
|
])
|
|
|
|
let byModel = try db.getUsageByModel()
|
|
#expect(byModel.count == 1)
|
|
#expect(byModel.first?.modelId == "claude-sonnet")
|
|
}
|
|
|
|
@Test("Overall stats respect an optional date range and count user questions")
|
|
func overallStatsRespectsDateRangeAndQuestions() throws {
|
|
let db = DatabaseService.makeInMemory()
|
|
let now = Date()
|
|
let old = now.addingTimeInterval(-10 * 24 * 3600)
|
|
|
|
_ = try db.saveConversation(name: "Old Chat", messages: [
|
|
Message(role: .user, content: "old question", tokens: 10, timestamp: old),
|
|
Message(role: .assistant, content: "old answer", tokens: 20, cost: 0.01, timestamp: old),
|
|
])
|
|
_ = try db.saveConversation(name: "Recent Chat", messages: [
|
|
Message(role: .user, content: "recent question", tokens: 5, timestamp: now),
|
|
Message(role: .assistant, content: "recent answer", tokens: 15, cost: 0.02, timestamp: now),
|
|
])
|
|
|
|
let rangeStats = try db.getOverallUsageStats(
|
|
from: now.addingTimeInterval(-3600), to: now.addingTimeInterval(3600)
|
|
)
|
|
#expect(rangeStats.totalMessages == 2)
|
|
#expect(rangeStats.totalQuestions == 1)
|
|
#expect(rangeStats.totalTokens == 20)
|
|
|
|
let allStats = try db.getOverallUsageStats()
|
|
#expect(allStats.totalMessages == 4)
|
|
#expect(allStats.totalQuestions == 2)
|
|
}
|
|
|
|
@Test("Usage by model respects an optional date range and reports question counts")
|
|
func usageByModelRespectsDateRangeAndQuestions() throws {
|
|
let db = DatabaseService.makeInMemory()
|
|
let now = Date()
|
|
let old = now.addingTimeInterval(-10 * 24 * 3600)
|
|
|
|
_ = try db.saveConversation(name: "Old Chat", messages: [
|
|
Message(role: .user, content: "hi", tokens: 5, timestamp: old, modelId: "claude-sonnet"),
|
|
Message(role: .assistant, content: "hello", tokens: 10, cost: 0.01, timestamp: old, modelId: "claude-sonnet"),
|
|
])
|
|
_ = try db.saveConversation(name: "Recent Chat", messages: [
|
|
Message(role: .user, content: "hey", tokens: 5, timestamp: now, modelId: "gpt-4"),
|
|
Message(role: .assistant, content: "hi", tokens: 15, cost: 0.02, timestamp: now, modelId: "gpt-4"),
|
|
])
|
|
|
|
let recent = try db.getUsageByModel(
|
|
from: now.addingTimeInterval(-3600), to: now.addingTimeInterval(3600)
|
|
)
|
|
#expect(recent.count == 1)
|
|
#expect(recent.first?.modelId == "gpt-4")
|
|
#expect(recent.first?.questionCount == 1)
|
|
}
|
|
|
|
@Test("Daily usage buckets messages by calendar day (GMT, matching stored timestamps)")
|
|
func dailyUsageBucketsByDay() throws {
|
|
let db = DatabaseService.makeInMemory()
|
|
var gmtCalendar = Calendar(identifier: .gregorian)
|
|
gmtCalendar.timeZone = TimeZone(identifier: "GMT")!
|
|
|
|
let day1 = gmtCalendar.date(from: DateComponents(year: 2026, month: 8, day: 1, hour: 10))!
|
|
let day2 = gmtCalendar.date(from: DateComponents(year: 2026, month: 8, day: 2, hour: 10))!
|
|
|
|
_ = try db.saveConversation(name: "Chat", messages: [
|
|
Message(role: .user, content: "q1", tokens: 5, timestamp: day1),
|
|
Message(role: .assistant, content: "a1", tokens: 10, cost: 0.01, timestamp: day1),
|
|
Message(role: .user, content: "q2", tokens: 5, timestamp: day2),
|
|
Message(role: .assistant, content: "a2", tokens: 20, cost: 0.02, timestamp: day2),
|
|
])
|
|
|
|
let daily = try db.getDailyUsage(
|
|
from: gmtCalendar.date(from: DateComponents(year: 2026, month: 8, day: 1))!,
|
|
to: gmtCalendar.date(from: DateComponents(year: 2026, month: 8, day: 3))!
|
|
)
|
|
#expect(daily.count == 2)
|
|
#expect(daily[0].totalTokens == 15)
|
|
#expect(daily[0].questionCount == 1)
|
|
#expect(daily[0].hasCostData == true)
|
|
#expect(daily[1].totalTokens == 25)
|
|
#expect(daily[1].questionCount == 1)
|
|
}
|
|
|
|
@Test("Usage by conversation joins conversation names and sorts by cost descending")
|
|
func usageByConversationSortsByCost() throws {
|
|
let db = DatabaseService.makeInMemory()
|
|
_ = try db.saveConversation(name: "Cheap Chat", messages: [
|
|
Message(role: .assistant, content: "a", tokens: 10, cost: 0.001, modelId: "m"),
|
|
])
|
|
_ = try db.saveConversation(name: "Expensive Chat", messages: [
|
|
Message(role: .assistant, content: "b", tokens: 10, cost: 0.05, modelId: "m"),
|
|
])
|
|
|
|
let byConversation = try db.getUsageByConversation()
|
|
#expect(byConversation.count == 2)
|
|
#expect(byConversation.first?.name == "Expensive Chat")
|
|
}
|
|
|
|
@Test("Usage by conversation respects the limit parameter")
|
|
func usageByConversationRespectsLimit() throws {
|
|
let db = DatabaseService.makeInMemory()
|
|
for i in 0..<5 {
|
|
_ = try db.saveConversation(name: "Chat \(i)", messages: [
|
|
Message(role: .assistant, content: "a", tokens: 10, cost: Double(i) * 0.01, modelId: "m"),
|
|
])
|
|
}
|
|
|
|
let byConversation = try db.getUsageByConversation(limit: 3)
|
|
#expect(byConversation.count == 3)
|
|
}
|
|
}
|
|
|
|
@Suite("DatabaseService usage events, against a throwaway in-memory queue")
|
|
struct DatabaseServiceUsageEventsTests {
|
|
|
|
@Test("Logged usage events are readable regardless of any saved conversation")
|
|
func logUsageEventPersistsWithNoConversation() throws {
|
|
let db = DatabaseService.makeInMemory()
|
|
try db.logUsageEvent(
|
|
provider: "openrouter", modelId: "gpt-5", promptTokens: 100, completionTokens: 50,
|
|
cost: 0.02, conversationId: nil
|
|
)
|
|
|
|
let totals = try db.getUsageEventTotals()
|
|
#expect(totals.totalQuestions == 1)
|
|
#expect(totals.totalTokens == 150)
|
|
#expect(totals.hasCostData == true)
|
|
#expect(abs(totals.totalCost - 0.02) < 0.0001)
|
|
}
|
|
|
|
@Test("Usage events respect an optional date range")
|
|
func usageEventTotalsRespectsDateRange() throws {
|
|
let db = DatabaseService.makeInMemory()
|
|
let now = Date()
|
|
let old = now.addingTimeInterval(-10 * 24 * 3600)
|
|
|
|
try db.logUsageEvent(provider: "openrouter", modelId: "gpt-5", promptTokens: 10, completionTokens: 10, cost: 0.01, conversationId: nil)
|
|
// Directly insert an old-dated row via the private record path isn't exposed, so approximate
|
|
// "old" coverage by asserting the fresh row is excluded when the range is set entirely in the past.
|
|
let rangeExcludingNow = try db.getUsageEventTotals(from: old, to: old.addingTimeInterval(3600))
|
|
#expect(rangeExcludingNow.totalQuestions == 0)
|
|
|
|
let rangeIncludingNow = try db.getUsageEventTotals(from: now.addingTimeInterval(-3600), to: now.addingTimeInterval(3600))
|
|
#expect(rangeIncludingNow.totalQuestions == 1)
|
|
}
|
|
|
|
@Test("Usage events group by model and by provider independently")
|
|
func usageEventsGroupByModelAndProvider() throws {
|
|
let db = DatabaseService.makeInMemory()
|
|
try db.logUsageEvent(provider: "openrouter", modelId: "gpt-5", promptTokens: 10, completionTokens: 10, cost: 0.01, conversationId: nil)
|
|
try db.logUsageEvent(provider: "openrouter", modelId: "claude-sonnet", promptTokens: 5, completionTokens: 5, cost: 0.02, conversationId: nil)
|
|
try db.logUsageEvent(provider: "anthropic", modelId: "claude-sonnet", promptTokens: 20, completionTokens: 20, cost: 0.03, conversationId: nil)
|
|
|
|
let byModel = try db.getUsageEventsByModel()
|
|
#expect(byModel.count == 2)
|
|
let sonnet = byModel.first { $0.modelId == "claude-sonnet" }
|
|
#expect(sonnet?.questionCount == 2)
|
|
#expect(sonnet?.totalTokens == 50)
|
|
|
|
let byProvider = try db.getUsageEventsByProvider()
|
|
#expect(byProvider.count == 2)
|
|
let openrouter = byProvider.first { $0.provider == "openrouter" }
|
|
#expect(openrouter?.questionCount == 2)
|
|
#expect(openrouter?.totalTokens == 30)
|
|
let anthropic = byProvider.first { $0.provider == "anthropic" }
|
|
#expect(anthropic?.questionCount == 1)
|
|
#expect(anthropic?.totalTokens == 40)
|
|
}
|
|
|
|
@Test("Daily usage events bucket by calendar day (GMT, matching stored timestamps)")
|
|
func dailyUsageEventsBucketByDay() throws {
|
|
let db = DatabaseService.makeInMemory()
|
|
try db.logUsageEvent(provider: "openrouter", modelId: "gpt-5", promptTokens: 10, completionTokens: 10, cost: 0.01, conversationId: nil)
|
|
|
|
var gmtCalendar = Calendar(identifier: .gregorian)
|
|
gmtCalendar.timeZone = TimeZone(identifier: "GMT")!
|
|
let farPast = gmtCalendar.date(from: DateComponents(year: 2020, month: 1, day: 1))!
|
|
let farFuture = gmtCalendar.date(from: DateComponents(year: 2030, month: 1, day: 1))!
|
|
|
|
let daily = try db.getDailyUsageEvents(from: farPast, to: farFuture)
|
|
#expect(daily.count == 1)
|
|
#expect(daily.first?.totalTokens == 20)
|
|
#expect(daily.first?.questionCount == 1)
|
|
}
|
|
}
|
|
|
|
@Suite("DatabaseService folders, against a throwaway in-memory queue")
|
|
struct DatabaseServiceFolderTests {
|
|
|
|
@Test("v9 adds the folders table and folderId to conversations")
|
|
func v9AddsFolderSupport() {
|
|
let db = DatabaseService.makeInMemory()
|
|
#expect(db.tableExists("folders"))
|
|
#expect(db.columnNames(in: "conversations").contains("folderId"))
|
|
}
|
|
|
|
@Test("v10 adds parentId to folders")
|
|
func v10AddsParentId() {
|
|
let db = DatabaseService.makeInMemory()
|
|
#expect(db.columnNames(in: "folders").contains("parentId"))
|
|
}
|
|
|
|
@Test("v11 adds updatedAt to folders")
|
|
func v11AddsUpdatedAt() {
|
|
let db = DatabaseService.makeInMemory()
|
|
#expect(db.columnNames(in: "folders").contains("updatedAt"))
|
|
}
|
|
|
|
@Test("renameFolder bumps updatedAt")
|
|
func renameFolderBumpsUpdatedAt() throws {
|
|
let db = DatabaseService.makeInMemory()
|
|
let folder = try db.createFolder(name: "Work")
|
|
// Round-trip through the DB for the "before" value too, so both sides go through the same
|
|
// fractional-seconds truncation as the "after" read below — comparing a raw in-memory
|
|
// Date() (full precision) against a DB-round-tripped one can flake when both timestamps
|
|
// land in the same millisecond window.
|
|
let originalUpdatedAt = try #require(db.listFolders().first(where: { $0.id == folder.id })?.updatedAt)
|
|
|
|
try db.renameFolder(id: folder.id, name: "Projects")
|
|
|
|
let updated = try db.listFolders().first(where: { $0.id == folder.id })
|
|
#expect(updated?.updatedAt ?? .distantPast >= originalUpdatedAt)
|
|
}
|
|
|
|
@Test("moveFolder bumps updatedAt")
|
|
func moveFolderBumpsUpdatedAt() throws {
|
|
let db = DatabaseService.makeInMemory()
|
|
let parent = try db.createFolder(name: "Work")
|
|
let child = try db.createFolder(name: "Personal")
|
|
// See renameFolderBumpsUpdatedAt's comment: round-trip through the DB for the "before"
|
|
// value so it's truncated the same way as the "after" read.
|
|
let originalUpdatedAt = try #require(db.listFolders().first(where: { $0.id == child.id })?.updatedAt)
|
|
|
|
try db.moveFolder(id: child.id, toParent: parent.id)
|
|
|
|
let updated = try db.listFolders().first(where: { $0.id == child.id })
|
|
#expect(updated?.updatedAt ?? .distantPast >= originalUpdatedAt)
|
|
}
|
|
|
|
@Test("upsertSyncedFolder creates a folder that doesn't exist locally yet")
|
|
func upsertSyncedFolderCreatesNew() throws {
|
|
let db = DatabaseService.makeInMemory()
|
|
let id = UUID()
|
|
let createdAt = Date(timeIntervalSince1970: 1_000)
|
|
let updatedAt = Date(timeIntervalSince1970: 2_000)
|
|
|
|
try db.upsertSyncedFolder(id: id, name: "Work", parentId: nil, createdAt: createdAt, updatedAt: updatedAt)
|
|
|
|
let folders = try db.listFolders()
|
|
let created = try #require(folders.first(where: { $0.id == id }))
|
|
#expect(created.name == "Work")
|
|
#expect(created.parentId == nil)
|
|
#expect(created.updatedAt == updatedAt)
|
|
}
|
|
|
|
@Test("upsertSyncedFolder is a no-op when the local version is the same age or newer")
|
|
func upsertSyncedFolderNoOpWhenLocalNotOlder() throws {
|
|
let db = DatabaseService.makeInMemory()
|
|
let id = UUID()
|
|
let createdAt = Date(timeIntervalSince1970: 1_000)
|
|
let localUpdatedAt = Date(timeIntervalSince1970: 5_000)
|
|
try db.upsertSyncedFolder(id: id, name: "Work", parentId: nil, createdAt: createdAt, updatedAt: localUpdatedAt)
|
|
|
|
// Incoming manifest entry is older than what's already local.
|
|
let staleIncomingUpdatedAt = Date(timeIntervalSince1970: 2_000)
|
|
try db.upsertSyncedFolder(id: id, name: "Renamed Elsewhere", parentId: nil, createdAt: createdAt, updatedAt: staleIncomingUpdatedAt)
|
|
|
|
let folders = try db.listFolders()
|
|
let unchanged = try #require(folders.first(where: { $0.id == id }))
|
|
#expect(unchanged.name == "Work")
|
|
#expect(unchanged.updatedAt == localUpdatedAt)
|
|
}
|
|
|
|
@Test("upsertSyncedFolder updates name and parent when the incoming version is newer")
|
|
func upsertSyncedFolderUpdatesWhenIncomingNewer() throws {
|
|
let db = DatabaseService.makeInMemory()
|
|
let id = UUID()
|
|
let otherParent = try db.createFolder(name: "Other Parent")
|
|
let createdAt = Date(timeIntervalSince1970: 1_000)
|
|
let localUpdatedAt = Date(timeIntervalSince1970: 2_000)
|
|
try db.upsertSyncedFolder(id: id, name: "Work", parentId: nil, createdAt: createdAt, updatedAt: localUpdatedAt)
|
|
|
|
let newerIncomingUpdatedAt = Date(timeIntervalSince1970: 9_000)
|
|
try db.upsertSyncedFolder(
|
|
id: id, name: "Projects", parentId: otherParent.id, createdAt: createdAt, updatedAt: newerIncomingUpdatedAt
|
|
)
|
|
|
|
let folders = try db.listFolders()
|
|
let updated = try #require(folders.first(where: { $0.id == id }))
|
|
#expect(updated.name == "Projects")
|
|
#expect(updated.parentId == otherParent.id)
|
|
#expect(updated.updatedAt == newerIncomingUpdatedAt)
|
|
}
|
|
|
|
@Test("createFolder(parentId:) nests the new folder under its parent")
|
|
func createFolderWithParent() throws {
|
|
let db = DatabaseService.makeInMemory()
|
|
let parent = try db.createFolder(name: "Work")
|
|
let child = try db.createFolder(name: "Project A", parentId: parent.id)
|
|
#expect(child.parentId == parent.id)
|
|
|
|
let folders = try db.listFolders()
|
|
#expect(folders.first(where: { $0.id == child.id })?.parentId == parent.id)
|
|
}
|
|
|
|
@Test("moveFolder reparents a folder")
|
|
func moveFolderReparents() throws {
|
|
let db = DatabaseService.makeInMemory()
|
|
let workFolder = try db.createFolder(name: "Work")
|
|
let personalFolder = try db.createFolder(name: "Personal")
|
|
|
|
try db.moveFolder(id: personalFolder.id, toParent: workFolder.id)
|
|
|
|
let folders = try db.listFolders()
|
|
#expect(folders.first(where: { $0.id == personalFolder.id })?.parentId == workFolder.id)
|
|
}
|
|
|
|
@Test("moveFolder promotes a nested folder to top-level when given nil")
|
|
func moveFolderPromotesToTopLevel() throws {
|
|
let db = DatabaseService.makeInMemory()
|
|
let parent = try db.createFolder(name: "Work")
|
|
let child = try db.createFolder(name: "Project A", parentId: parent.id)
|
|
|
|
try db.moveFolder(id: child.id, toParent: nil)
|
|
|
|
let folders = try db.listFolders()
|
|
#expect(folders.first(where: { $0.id == child.id })?.parentId == nil)
|
|
}
|
|
|
|
@Test("moveFolder throws wouldCreateCycle when reparenting a folder under itself")
|
|
func moveFolderSelfReparentThrows() throws {
|
|
let db = DatabaseService.makeInMemory()
|
|
let folder = try db.createFolder(name: "Work")
|
|
#expect(throws: DatabaseService.FolderError.wouldCreateCycle) {
|
|
try db.moveFolder(id: folder.id, toParent: folder.id)
|
|
}
|
|
}
|
|
|
|
@Test("moveFolder throws wouldCreateCycle when reparenting an ancestor under its own descendant")
|
|
func moveFolderAncestorUnderDescendantThrows() throws {
|
|
let db = DatabaseService.makeInMemory()
|
|
let grandparent = try db.createFolder(name: "Work")
|
|
let parent = try db.createFolder(name: "Project A", parentId: grandparent.id)
|
|
let child = try db.createFolder(name: "Sub-task", parentId: parent.id)
|
|
|
|
#expect(throws: DatabaseService.FolderError.wouldCreateCycle) {
|
|
try db.moveFolder(id: grandparent.id, toParent: child.id)
|
|
}
|
|
}
|
|
|
|
@Test("Creating folders assigns increasing sort order")
|
|
func createFolderAssignsSortOrder() throws {
|
|
let db = DatabaseService.makeInMemory()
|
|
let first = try db.createFolder(name: "Work")
|
|
let second = try db.createFolder(name: "Personal")
|
|
#expect(first.sortOrder == 0)
|
|
#expect(second.sortOrder == 1)
|
|
}
|
|
|
|
@Test("listFolders returns folders sorted alphabetically, case-insensitive, regardless of creation order")
|
|
func listFoldersOrdered() throws {
|
|
let db = DatabaseService.makeInMemory()
|
|
_ = try db.createFolder(name: "Work")
|
|
_ = try db.createFolder(name: "apple")
|
|
_ = try db.createFolder(name: "Personal")
|
|
|
|
let folders = try db.listFolders()
|
|
#expect(folders.map(\.name) == ["apple", "Personal", "Work"])
|
|
}
|
|
|
|
@Test("renameFolder updates the stored name")
|
|
func renameFolderUpdatesName() throws {
|
|
let db = DatabaseService.makeInMemory()
|
|
let folder = try db.createFolder(name: "Work")
|
|
try db.renameFolder(id: folder.id, name: "Projects")
|
|
|
|
let folders = try db.listFolders()
|
|
#expect(folders.first?.name == "Projects")
|
|
}
|
|
|
|
@Test("moveConversation files a conversation into a folder")
|
|
func moveConversationFiles() throws {
|
|
let db = DatabaseService.makeInMemory()
|
|
let folder = try db.createFolder(name: "Work")
|
|
let conversation = try db.saveConversation(name: "Chat", messages: [Message(role: .user, content: "hi")])
|
|
|
|
try db.moveConversation(id: conversation.id, toFolder: folder.id)
|
|
|
|
let loaded = try db.loadConversation(id: conversation.id)
|
|
#expect(loaded?.0.folderId == folder.id)
|
|
}
|
|
|
|
@Test("moveConversation with nil folder unfiles a conversation")
|
|
func moveConversationUnfiles() throws {
|
|
let db = DatabaseService.makeInMemory()
|
|
let folder = try db.createFolder(name: "Work")
|
|
let conversation = try db.saveConversation(name: "Chat", messages: [Message(role: .user, content: "hi")])
|
|
try db.moveConversation(id: conversation.id, toFolder: folder.id)
|
|
|
|
try db.moveConversation(id: conversation.id, toFolder: nil)
|
|
|
|
let loaded = try db.loadConversation(id: conversation.id)
|
|
#expect(loaded?.0.folderId == nil)
|
|
}
|
|
|
|
@Test("Deleting a folder unfiles its conversations instead of deleting them")
|
|
func deleteFolderUnfilesConversations() throws {
|
|
let db = DatabaseService.makeInMemory()
|
|
let folder = try db.createFolder(name: "Work")
|
|
let conversation = try db.saveConversation(name: "Chat", messages: [Message(role: .user, content: "hi")])
|
|
try db.moveConversation(id: conversation.id, toFolder: folder.id)
|
|
|
|
try db.deleteFolder(id: folder.id)
|
|
|
|
let folders = try db.listFolders()
|
|
#expect(folders.isEmpty)
|
|
|
|
let loaded = try db.loadConversation(id: conversation.id)
|
|
#expect(loaded != nil)
|
|
#expect(loaded?.0.folderId == nil)
|
|
}
|
|
|
|
@Test("Deleting a nested folder reparents its children and conversations up one level, not to top-level")
|
|
func deleteNestedFolderReparentsUpOneLevel() throws {
|
|
let db = DatabaseService.makeInMemory()
|
|
let work = try db.createFolder(name: "Work")
|
|
let projectA = try db.createFolder(name: "Project A", parentId: work.id)
|
|
let subTask = try db.createFolder(name: "Sub-task", parentId: projectA.id)
|
|
let conversation = try db.saveConversation(name: "Chat", messages: [Message(role: .user, content: "hi")])
|
|
try db.moveConversation(id: conversation.id, toFolder: projectA.id)
|
|
|
|
try db.deleteFolder(id: projectA.id)
|
|
|
|
let folders = try db.listFolders()
|
|
#expect(folders.first(where: { $0.id == subTask.id })?.parentId == work.id)
|
|
#expect(folders.contains(where: { $0.id == projectA.id }) == false)
|
|
|
|
let loaded = try db.loadConversation(id: conversation.id)
|
|
#expect(loaded?.0.folderId == work.id)
|
|
}
|
|
|
|
@Test("listConversations reflects folderId")
|
|
func listConversationsReflectsFolderId() throws {
|
|
let db = DatabaseService.makeInMemory()
|
|
let folder = try db.createFolder(name: "Work")
|
|
let conversation = try db.saveConversation(name: "Chat", messages: [Message(role: .user, content: "hi")])
|
|
try db.moveConversation(id: conversation.id, toFolder: folder.id)
|
|
|
|
let conversations = try db.listConversations()
|
|
#expect(conversations.first?.folderId == folder.id)
|
|
}
|
|
}
|
|
|
|
@Suite("DatabaseService per-conversation notes (v12), against a throwaway in-memory queue")
|
|
struct DatabaseServiceNotesTests {
|
|
|
|
@Test("conversations table has notesEnabled and notesFilename columns after v12")
|
|
func v12AddsNotesColumns() {
|
|
let db = DatabaseService.makeInMemory()
|
|
let columns = Set(db.columnNames(in: "conversations"))
|
|
#expect(columns.contains("notesEnabled"))
|
|
#expect(columns.contains("notesFilename"))
|
|
}
|
|
|
|
@Test("A newly saved conversation defaults to notes disabled with no filename")
|
|
func newConversationDefaultsToNotesDisabled() throws {
|
|
let db = DatabaseService.makeInMemory()
|
|
let saved = try db.saveConversation(name: "Chat", messages: [Message(role: .user, content: "hi")])
|
|
#expect(saved.notesEnabled == false)
|
|
#expect(saved.notesFilename == nil)
|
|
|
|
let loaded = try db.loadConversation(id: saved.id)
|
|
#expect(loaded?.0.notesEnabled == false)
|
|
#expect(loaded?.0.notesFilename == nil)
|
|
}
|
|
|
|
@Test("setNotesEnabled persists and round-trips through loadConversation")
|
|
func setNotesEnabledRoundTrips() throws {
|
|
let db = DatabaseService.makeInMemory()
|
|
let saved = try db.saveConversation(name: "Chat", messages: [Message(role: .user, content: "hi")])
|
|
|
|
try db.setNotesEnabled(id: saved.id, enabled: true)
|
|
|
|
let loaded = try db.loadConversation(id: saved.id)
|
|
#expect(loaded?.0.notesEnabled == true)
|
|
}
|
|
|
|
@Test("setNotesFilename persists and round-trips through loadConversation")
|
|
func setNotesFilenameRoundTrips() throws {
|
|
let db = DatabaseService.makeInMemory()
|
|
let saved = try db.saveConversation(name: "Chat", messages: [Message(role: .user, content: "hi")])
|
|
|
|
try db.setNotesFilename(id: saved.id, filename: "Chat-a3f2.md")
|
|
|
|
let loaded = try db.loadConversation(id: saved.id)
|
|
#expect(loaded?.0.notesFilename == "Chat-a3f2.md")
|
|
}
|
|
|
|
@Test("listConversations reflects notesEnabled and notesFilename")
|
|
func listConversationsReflectsNotes() throws {
|
|
let db = DatabaseService.makeInMemory()
|
|
let saved = try db.saveConversation(name: "Chat", messages: [Message(role: .user, content: "hi")])
|
|
try db.setNotesEnabled(id: saved.id, enabled: true)
|
|
try db.setNotesFilename(id: saved.id, filename: "Chat-a3f2.md")
|
|
|
|
let conversations = try db.listConversations()
|
|
#expect(conversations.first?.notesEnabled == true)
|
|
#expect(conversations.first?.notesFilename == "Chat-a3f2.md")
|
|
}
|
|
}
|