Phase 4 tests: DatabaseService migrations + ContextSelectionService DB paths
17 tests against throwaway in-memory DatabaseService instances: all v1-v8 tables/columns exist post-migration, settings CRUD, conversation save/load round-trip, instance isolation between separate in-memory queues, and ContextSelectionService's DB-coupled paths (starring, excluded-range summaries, smartSelection end to end) that were previously untestable.
This commit is contained in:
@@ -0,0 +1,120 @@
|
|||||||
|
//
|
||||||
|
// ContextSelectionServiceDBTests.swift
|
||||||
|
// oAITests
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
|
// Copyright (C) 2026 Rune Olsen
|
||||||
|
|
||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import oAI
|
||||||
|
|
||||||
|
@Suite("ContextSelectionService DB-coupled paths, against a throwaway in-memory queue")
|
||||||
|
struct ContextSelectionServiceDBTests {
|
||||||
|
|
||||||
|
/// Persists messages, then loads them back so their `id`s match the DB rows
|
||||||
|
/// (saveConversation assigns fresh row ids internally, distinct from the ids
|
||||||
|
/// on the Message values passed in).
|
||||||
|
private func persistedMessages(_ db: DatabaseService, _ messages: [Message]) throws -> (conversationId: UUID, messages: [Message]) {
|
||||||
|
let saved = try db.saveConversation(name: "Test", messages: messages)
|
||||||
|
let loaded = try db.loadConversation(id: saved.id)
|
||||||
|
return (saved.id, loaded?.1 ?? [])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - isMessageStarred
|
||||||
|
|
||||||
|
@Test("A message with no metadata row is not starred")
|
||||||
|
func unstarredByDefault() throws {
|
||||||
|
let db = DatabaseService.makeInMemory()
|
||||||
|
let service = ContextSelectionService(db: db)
|
||||||
|
let (_, messages) = try persistedMessages(db, [Message(role: .user, content: "hi")])
|
||||||
|
|
||||||
|
#expect(service.isMessageStarred(messages[0]) == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A message starred via setMessageStarred reports starred")
|
||||||
|
func starredAfterSetting() throws {
|
||||||
|
let db = DatabaseService.makeInMemory()
|
||||||
|
let service = ContextSelectionService(db: db)
|
||||||
|
let (_, messages) = try persistedMessages(db, [Message(role: .user, content: "hi")])
|
||||||
|
|
||||||
|
try db.setMessageStarred(messageId: messages[0].id, starred: true)
|
||||||
|
#expect(service.isMessageStarred(messages[0]) == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - getSummariesForExcludedRange
|
||||||
|
|
||||||
|
@Test("No summaries recorded returns an empty array")
|
||||||
|
func noSummariesReturnsEmpty() throws {
|
||||||
|
let db = DatabaseService.makeInMemory()
|
||||||
|
let service = ContextSelectionService(db: db)
|
||||||
|
let (conversationId, _) = try persistedMessages(db, [Message(role: .user, content: "hi")])
|
||||||
|
|
||||||
|
let summaries = service.getSummariesForExcludedRange(conversationId: conversationId, totalMessages: 30, selectedCount: 10)
|
||||||
|
#expect(summaries.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A summary covering only excluded messages is included")
|
||||||
|
func summaryForExcludedRangeIncluded() throws {
|
||||||
|
let db = DatabaseService.makeInMemory()
|
||||||
|
let service = ContextSelectionService(db: db)
|
||||||
|
let (conversationId, _) = try persistedMessages(db, [Message(role: .user, content: "hi")])
|
||||||
|
|
||||||
|
// 30 total, 10 selected -> messages 0..<20 are excluded
|
||||||
|
try db.saveConversationSummary(conversationId: conversationId, startIndex: 0, endIndex: 15, summary: "early stuff", model: nil, tokenCount: nil)
|
||||||
|
|
||||||
|
let summaries = service.getSummariesForExcludedRange(conversationId: conversationId, totalMessages: 30, selectedCount: 10)
|
||||||
|
#expect(summaries == ["early stuff"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A summary covering messages that were kept is excluded")
|
||||||
|
func summaryForKeptRangeExcluded() throws {
|
||||||
|
let db = DatabaseService.makeInMemory()
|
||||||
|
let service = ContextSelectionService(db: db)
|
||||||
|
let (conversationId, _) = try persistedMessages(db, [Message(role: .user, content: "hi")])
|
||||||
|
|
||||||
|
// 30 total, 10 selected -> messages 0..<20 are excluded; this summary ends at 25, inside the kept range
|
||||||
|
try db.saveConversationSummary(conversationId: conversationId, startIndex: 15, endIndex: 25, summary: "later stuff", model: nil, tokenCount: nil)
|
||||||
|
|
||||||
|
let summaries = service.getSummariesForExcludedRange(conversationId: conversationId, totalMessages: 30, selectedCount: 10)
|
||||||
|
#expect(summaries.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - smartSelection (end to end through the DB seam)
|
||||||
|
|
||||||
|
@Test("A starred older message is pulled in alongside the recent window")
|
||||||
|
func starredMessagePulledIntoSelection() throws {
|
||||||
|
let db = DatabaseService.makeInMemory()
|
||||||
|
let service = ContextSelectionService(db: db)
|
||||||
|
|
||||||
|
var all: [Message] = []
|
||||||
|
let base = Date()
|
||||||
|
for i in 0..<15 {
|
||||||
|
all.append(Message(role: i % 2 == 0 ? .user : .assistant, content: "msg \(i)", timestamp: base.addingTimeInterval(Double(i))))
|
||||||
|
}
|
||||||
|
let (_, persisted) = try persistedMessages(db, all)
|
||||||
|
|
||||||
|
// Star an old message that's outside the last-10 window (index 2)
|
||||||
|
try db.setMessageStarred(messageId: persisted[2].id, starred: true)
|
||||||
|
|
||||||
|
let window = service.smartSelection(allMessages: persisted, maxTokens: 100_000, conversationId: nil)
|
||||||
|
#expect(window.messages.contains { $0.id == persisted[2].id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An unstarred, unimportant old message outside the recent window is excluded")
|
||||||
|
func unstarredOldMessageExcluded() throws {
|
||||||
|
let db = DatabaseService.makeInMemory()
|
||||||
|
let service = ContextSelectionService(db: db)
|
||||||
|
|
||||||
|
var all: [Message] = []
|
||||||
|
let base = Date()
|
||||||
|
for i in 0..<15 {
|
||||||
|
all.append(Message(role: i % 2 == 0 ? .user : .assistant, content: "msg \(i)", timestamp: base.addingTimeInterval(Double(i))))
|
||||||
|
}
|
||||||
|
let (_, persisted) = try persistedMessages(db, all)
|
||||||
|
|
||||||
|
let window = service.smartSelection(allMessages: persisted, maxTokens: 100_000, conversationId: nil)
|
||||||
|
#expect(window.messages.contains { $0.id == persisted[0].id } == false)
|
||||||
|
#expect(window.excludedCount == 5)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
//
|
||||||
|
// DatabaseServiceTests.swift
|
||||||
|
// oAITests
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
|
// Copyright (C) 2026 Rune Olsen
|
||||||
|
|
||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import oAI
|
||||||
|
|
||||||
|
@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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user