Files
oai-swift/oAITests/DatabaseServiceTests.swift
rune 2668555b98 Add nested (hierarchical) conversation folders
Folders can now contain other folders, arbitrarily deep — e.g. "Work"
containing "Project A"/"Project B". v10 migration adds a
self-referencing parentId column; tree ordering, depth, and cycle
detection are pure Swift (Folder.orderedTree/isDescendant/
visibleFolderIds), not SQL, so listFolders() stays a simple flat
query.

- Create nested folders via "New Subfolder…" (context menu, both
  list views) or by dragging a folder onto another to reparent it.
  Dragging onto an existing descendant is rejected (cycle guard).
- Deleting a folder reparents its children and any conversations
  filed directly in it up one level to the deleted folder's own
  parent — conversations are never deleted. This also fixes a real
  bug: the previous deleteFolder never persisted unfiling to the
  database, only patched in-memory state, so a conversation whose
  folder was deleted kept a dangling folderId and silently vanished
  from view after the next relaunch.
- All "Move to Folder" pickers (sidebar, advanced list, per-row
  context menus, the Save dialog's folder popup) show an indented
  flat list reflecting the tree.
- New DraggedItem enum disambiguates a dragged folder from dragged
  conversation(s) in the shared string-based drag payload, and
  unifies both list views on the same bundled-multi-selection format
  — closes a gap where dragging a multi-selection in the advanced
  list (⌘L) only moved the one row grabbed, unlike the sidebar.

Confirmed working live, including relaunch-survival of the
delete/reparent fix.
2026-08-02 17:52:10 +02:00

398 lines
16 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("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("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("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)
}
}