// // 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("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 = ["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("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 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") } }